JavaScript Promises in Depth: Chaining, Errors, and Combinators

July 6, 2026 · 10 min read

Promises are the foundation everything async in JavaScript is built on — fetch, async/await, dynamic import(). Most of us use them daily while carrying a slightly fuzzy model of how they behave, and the fuzz shows up as unhandled rejections, accidental sequential code, and .then chains that silently swallow values. This post nails down the actual rules. (For when promise callbacks run — microtasks and the event loop — see the event loop post; this one is about what they do.)

Three states, one transition

pendingresolve(value)reject(reason)fulfilledrejected

A promise settles exactly once — fulfilled or rejected — and never changes state again. Extra resolve/reject calls are silently ignored.

A promise is a state machine with exactly one move: from pending to either fulfilled (with a value) or rejected (with a reason). That transition happens once. Settle a promise twice and the second call is silently ignored:

const p = new Promise((resolve, reject) => {
  resolve("first");
  resolve("second"); // ignored
  reject("nope");    // also ignored
});

This one-shot guarantee is the entire value proposition: a promise is a container you can hand to five different consumers, attach callbacks before or after it settles, and everyone observes the same immutable outcome. Compare that to a raw callback, which you just have to trust gets called exactly once. A buggy callback-based library can call you twice, or never, or call you synchronously sometimes and asynchronously other times; a promise rules out all three.

The chaining rules

.then doesn't just attach a callback — it returns a new promise, whose fate is decided by what your callback does. There are only three cases, and they're worth memorizing:

  1. Return a plain value → the new promise fulfills with that value.
  2. Return a promise → the new promise adopts it: waits for it and mirrors its outcome. This is why chains flatten instead of nesting.
  3. Throw → the new promise rejects with the thrown error.
fetch("/api/user")
  .then((res) => {
    if (!res.ok) throw new Error(`HTTP ${res.status}`); // rule 3
    return res.json();                                  // rule 2 (a promise)
  })
  .then((user) => user.name)                            // rule 1 (a value)
  .then((name) => console.log(name))
  .catch((err) => console.error("somewhere above failed:", err));

Two practical corollaries:

  • Forgetting return breaks the chain. If a callback runs savePromise but doesn't return it, the next .then fires immediately with undefined, and errors from the orphaned promise escape the chain's .catch entirely. This is probably the most common promise bug in existence.
  • Nesting is a smell. Any time you write .then(x => f(x).then(...)) you almost certainly wanted to return f(x) and chain at the top level.

How errors travel

A rejection skips forward past every fulfillment handler until it meets a rejection handler — exactly like a thrown exception unwinding past stack frames until a catch. And .catch(fn) is literally .then(undefined, fn).

One subtlety worth knowing: .then(onFulfilled, onRejected) and .then(onFulfilled).catch(onRejected) are not the same. The two-argument form can't catch errors thrown by its own onFulfilled; the chained .catch can, because it sits downstream of it.

After a .catch, the chain is recovered — whatever the catch handler returns becomes a normal fulfillment for the next link. That's a feature (fallback values) and a footgun (accidentally continuing a pipeline that should have stopped). Re-throw if you only meant to log:

.catch((err) => {
  reportError(err);
  throw err; // keep the chain rejected
})

If nothing in the chain ever handles a rejection, the runtime raises unhandledrejection (browsers) or, in Node, crashes the process by default — promises make it loud when you drop errors on the floor.

The combinators

Four static methods coordinate multiple promises, differing along two axes: do you need all results or just one, and what should a failure do?

CombinatorResolves withRejects whenUse for
Promise.allArray of all values (order preserved)Any one rejects (fail fast)Parallel work where every piece is required
Promise.allSettledArray of { status, value | reason }NeverBatch jobs where partial success is fine
Promise.raceFirst to settle — fulfilled or rejectedFirst settler rejectedTimeouts, "whichever responds first"
Promise.anyFirst to fulfillAll reject (AggregateError)Redundant sources, fastest mirror wins

The pattern that shows up constantly — independent requests should run in parallel, and await makes it deceptively easy not to:

// Sequential by accident: ~600ms if each takes 200ms
const user = await fetchUser();
const posts = await fetchPosts();
const tags = await fetchTags();

// Parallel on purpose: ~200ms
const [user2, posts2, tags2] = await Promise.all([
  fetchUser(),
  fetchPosts(),
  fetchTags(),
]);

Each bare await parks the function until that promise settles, so three independent fetches quietly serialize. The same trap wears a second costume as await inside a loop — when iterations don't depend on each other, build an array of promises and Promise.all it.

And race gives you timeouts in four lines:

function withTimeout(promise, ms) {
  const timeout = new Promise((_, reject) =>
    setTimeout(() => reject(new Error(`timed out after ${ms}ms`)), ms)
  );
  return Promise.race([promise, timeout]);
}

(One honest caveat: losing promises aren't cancelled — the slow fetch keeps running, you just stop waiting for it. Actual cancellation needs AbortController.)

Concurrency with a limit

Promise.all over 10,000 items starts 10,000 requests at once — usually a good way to get rate-limited, exhaust a connection pool, or run out of file descriptors. What you usually want is "parallel, but at most N at a time":

async function mapWithLimit(items, limit, fn) {
  const results = new Array(items.length);
  let next = 0;
  async function worker() {
    while (next < items.length) {
      const i = next++;                    // safe: no await between read and increment
      results[i] = await fn(items[i], i);
    }
  }
  await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
  return results;
}

const pages = await mapWithLimit(urls, 5, (url) => fetch(url).then((r) => r.text()));

Five "workers" pull from a shared index until the list is empty. Results keep input order, the first rejection rejects the whole call (like Promise.all), and at most five requests are ever in flight. Because JavaScript runs one callback at a time, next++ needs no lock — there's no await between reading and incrementing it.

Turning callbacks into promises

Older APIs take a callback. Wrapping one is a one-time job:

function readFileAsync(path) {
  return new Promise((resolve, reject) => {
    fs.readFile(path, 'utf8', (err, data) => (err ? reject(err) : resolve(data)));
  });
}

For Node-style (err, value) callbacks, util.promisify does it for you, and most of Node's core modules now ship promise versions anyway (node:fs/promises, node:timers/promises). For event-style APIs where the resolver needs to escape the executor, Promise.withResolvers() hands you the promise and its resolve/reject together:

const { promise, resolve, reject } = Promise.withResolvers();
socket.once('ready', resolve);
socket.once('error', reject);
await promise;

Real cancellation

The race-based timeout above stops waiting, not working. For fetch and many newer APIs, pass an AbortSignal, and the underlying operation is actually cancelled:

const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
// rejects with a TimeoutError after 5 s, and the request is torn down

Signals compose — AbortSignal.any([userCancel, AbortSignal.timeout(5000)]) aborts on whichever comes first — and you can accept a signal in your own async functions and pass it down, so cancelling the top-level operation cancels everything beneath it. See AbortController and cancellation.

Unhandled rejections, in detail

A rejection is "unhandled" if no handler is attached by the time the microtask queue drains after it rejects. Two common ways to get one without noticing:

const p = fetchUser();          // starts, then rejects…
await sleep(1000);              // …while nothing is listening
await p;                        // too late: already reported as unhandled
items.forEach(async (item) => { await save(item); });   // forEach ignores the promises

The first starts a promise and attaches a handler later; start it when you're ready to await it, or attach a .catch immediately. The second fires off promises nobody awaits — any rejection escapes, and the code after the forEach runs before the saves finish. Use for...of with await, or Promise.all(items.map(save)).

In Node, an unhandled rejection crashes the process by default. Register a process.on('unhandledRejection', …) handler to log it with context — but treat it as a bug report, not a recovery mechanism.

Common bugs at a glance

you seelikely causefix
next .then gets undefinedmissing return in the previous callbackreturn the promise or value
error not caught by .catchan orphaned, unreturned promise inside a callbackreturn it so it joins the chain
code after a loop runs too earlyasync callback in forEachfor...of with await, or Promise.all(map)
slow page despite fast APIssequential awaits on independent workPromise.all
429s or pool exhaustionunbounded Promise.all over a big lista concurrency limit
timeout fires but work continuesrace-based timeoutAbortSignal

A promise in thirty lines

Writing a toy promise is the fastest way to make the three chaining rules stick, because the implementation is the rules:

class TinyPromise {
  #state = 'pending'; #value; #handlers = [];

  constructor(executor) {
    const settle = (state) => (value) => {
      if (this.#state !== 'pending') return;            // one transition only
      if (state === 'fulfilled' && value && typeof value.then === 'function') {
        return value.then(settle('fulfilled'), settle('rejected'));  // adopt
      }
      this.#state = state; this.#value = value;
      this.#handlers.forEach((h) => queueMicrotask(h));  // callbacks are async
    };
    try { executor(settle('fulfilled'), settle('rejected')) }
    catch (err) { settle('rejected')(err) }
  }

  then(onFulfilled, onRejected) {
    return new TinyPromise((resolve, reject) => {
      const run = () => {
        const cb = this.#state === 'fulfilled' ? onFulfilled : onRejected;
        if (typeof cb !== 'function') {                  // pass through
          return (this.#state === 'fulfilled' ? resolve : reject)(this.#value);
        }
        try { resolve(cb(this.#value)) }                 // rules 1 and 2
        catch (err) { reject(err) }                      // rule 3
      };
      if (this.#state === 'pending') this.#handlers.push(run);
      else queueMicrotask(run);
    });
  }

  catch(onRejected) { return this.then(undefined, onRejected) }
}

Every behaviour from this post is visible in it: settling twice is ignored; then always returns a new promise; a returned value fulfils it, a returned promise is adopted (via resolve's check for a .then), and a throw rejects it; a missing handler passes the outcome through, which is how rejections skip forward to the nearest catch; and callbacks always run as microtasks, even if the promise is already settled. The real specification adds edge cases — a promise resolved with itself, thenables whose then throws — but not new ideas.

The "adopt anything with a .then method" step has a name: thenable assimilation. It's why promises from different libraries, and objects like query builders that define then, can all be awaited.

Odds and ends worth knowing

  • Promise.resolve(x) is idempotent — if x is already a promise it returns it as-is, which makes it the standard way to normalize "value-or-promise" inputs.
  • The executor runs synchronously — new Promise(fn) calls fn immediately; only the .then callbacks are deferred (to the microtask queue).
  • .finally passes through — it sees no value and returns the chain's outcome unchanged; perfect for hiding spinners regardless of result.
  • async functions are promise factories — return x fulfills, throw rejects, and callers compose them with the same combinators.
  • allSettled results need reading. It never rejects, so failures are easy to ignore. Filter for status === 'rejected' and log or retry them, or you've built a batch job that silently drops errors.
  • AggregateError from Promise.any carries every rejection in its errors array — useful when all mirrors failed and you need to say why.
  • Don't mix styles in one chain. Code that awaits some promises and .thens others is harder to follow than either style alone; in modern code, await with try/catch is the default, and .then earns its place in small one-line transformations. See async/await under the hood.

The mental model that ties it together: a promise chain is a pipeline of one-shot containers, each link deciding the next one's fate by value, promise, or throw — with rejections riding an express lane to the nearest catch. Every confusing chain becomes legible once you trace it link by link with those three rules. And when a chain is too tangled to trace, that's usually the signal to rewrite it with await — the rules don't change, but the control flow becomes ordinary code again.