Skip to content
JavaScript js async 4 min read

Promises

A Promise is an object that represents the eventual result of an asynchronous operation. Instead of passing a callback into a function and waiting for it to fire, you receive a promise immediately and attach handlers to it. Promises give async code a uniform shape, make errors composable, and form the foundation that async/await is built on top of. Understanding them is essential to writing reliable, readable asynchronous JavaScript.

The promise lifecycle

A promise is always in one of three states:

StateMeaningTransitions to
pendingThe operation has not completed yet.fulfilled or rejected
fulfilledThe operation succeeded and produced a value.(final)
rejectedThe operation failed and produced a reason (usually an Error).(final)

Once a promise leaves pending it is settled, and its state and value can never change again. This immutability is what makes promises safe to pass around and attach multiple handlers to. A settled promise that you then() later will still deliver its result.

            ┌──────────────┐  resolve(value)   ┌──────────────┐
            │              │ ────────────────▶ │  fulfilled   │
  new ────▶ │   pending    │                   └──────────────┘
            │              │  reject(reason)   ┌──────────────┐
            └──────────────┘ ────────────────▶ │  rejected    │
                                               └──────────────┘

Creating a promise

You construct a promise with the Promise constructor, passing an executor function. The executor receives two functions—resolve and reject—that you call to settle the promise. The executor runs synchronously and immediately.

function wait(ms) {
  return new Promise((resolve, reject) => {
    if (ms < 0) {
      reject(new Error("Delay must be non-negative"));
      return;
    }
    setTimeout(() => resolve(`waited ${ms}ms`), ms);
  });
}

wait(200).then((msg) => console.log(msg));

Output:

waited 200ms

For values you already have, the static helpers are shorter than the constructor. Promise.resolve(value) returns an already-fulfilled promise, and Promise.reject(reason) returns an already-rejected one.

Promise.resolve(42).then((n) => console.log(n)); // 42

Only the first call to resolve or reject matters—subsequent calls are ignored. A promise cannot be “un-settled,” so guard your executor logic accordingly.

Consuming with then, catch, and finally

You read a promise’s outcome with three methods. Each returns a new promise, which is what makes chaining possible.

  • then(onFulfilled, onRejected) — runs onFulfilled with the value when fulfilled. The optional second argument handles rejection.
  • catch(onRejected) — handles a rejection anywhere earlier in the chain. Equivalent to then(undefined, onRejected).
  • finally(onSettled) — runs once the promise settles, regardless of outcome. It receives no argument and is ideal for cleanup.
function fetchUser(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id === 1) resolve({ id, name: "Ada" });
      else reject(new Error(`No user with id ${id}`));
    }, 100);
  });
}

fetchUser(1)
  .then((user) => console.log("Got:", user.name))
  .catch((err) => console.error("Failed:", err.message))
  .finally(() => console.log("Request complete"));

fetchUser(99)
  .then((user) => console.log("Got:", user.name))
  .catch((err) => console.error("Failed:", err.message))
  .finally(() => console.log("Request complete"));

Output:

Got: Ada
Request complete
Failed: No user with id 99
Request complete

Because handlers are queued as microtasks, they always run after the current synchronous code finishes—even for an already-settled promise. This guarantees consistent, predictable ordering.

console.log("start");
Promise.resolve("now").then((v) => console.log(v));
console.log("end");

Output:

start
end
now

Converting callbacks to promises

Older Node-style APIs use error-first callbacks: callback(err, result). You can wrap any such API in a promise so it fits modern control flow. This pattern is called promisification.

import { readFile } from "node:fs";

function readFileAsync(path, encoding = "utf8") {
  return new Promise((resolve, reject) => {
    readFile(path, encoding, (err, data) => {
      if (err) reject(err);
      else resolve(data);
    });
  });
}

readFileAsync("./config.json")
  .then((text) => console.log("Read", text.length, "chars"))
  .catch((err) => console.error("Read failed:", err.message));

Node ships util.promisify to do this automatically for any error-first function, and most core modules now expose promise versions directly (e.g. node:fs/promises).

import { promisify } from "node:util";
import { readFile } from "node:fs";

const readFileP = promisify(readFile);
readFileP("./config.json", "utf8").then((text) => console.log(text.length));

A thrown exception inside then/catch/executor automatically rejects the returned promise. Never wrap promise-returning code in try/catch expecting to catch async failures—use .catch() instead.

Best Practices

  • Always attach a .catch() (or terminal rejection handler) to every chain—unhandled rejections crash Node processes and warn in browsers.
  • Return values from inside then callbacks so the next handler receives them; forgetting to return is the most common chaining bug.
  • Reject with Error objects, not strings, so you keep stack traces.
  • Prefer the static helpers Promise.resolve/reject and existing promise APIs over hand-rolling the constructor.
  • Use finally for cleanup (closing connections, hiding spinners) rather than duplicating it in both then and catch.
  • Reach for async/await for sequential logic, but keep promises for combinators like Promise.all.
Last updated June 1, 2026
Was this helpful?