Engineering Notes
Home/Languages/JavaScript/Promise Chaining
Languages · JavaScript

Promise Chaining

Because every .then() returns a new Promise (see the Promises topic), chains can be built to any length, with each step transforming the previous result.

💻 Languages● Advanced⏱ 5 min read🛠️ 3 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • The Mechanism: Each .then() Returns a New Promise
  • Returning a Promise from .then() — Automatic Flattening
  • Returning a Plain Value — Automatically Wrapped
  • Error Propagation Skips Directly to the Next .catch()
  • Placing .catch() Strategically

1The Mechanism: Each .then() Returns a New Promise

js
Promise.resolve(1)
  .then(n => n + 1)   // returns a new Promise resolved with 2
  .then(n => n * 10)   // returns a new Promise resolved with 20
  .then(n => console.log(n)); // 20

Because every .then() returns a new Promise (see the Promises topic), chains can be built to any length, with each step transforming the previous result.

2Returning a Promise from .then() — Automatic Flattening

If a .then() callback returns another Promise, the chain automatically waits for it and unwraps its value — it does not nest Promises inside Promises.

js
function fetchUser(id) { return Promise.resolve({ id, name: "Ana" }); }
function fetchOrders(userId) { return Promise.resolve([{ total: 20 }]); }

fetchUser(1)
  .then(user => fetchOrders(user.id))  // returns a Promise — the chain WAITS for it
  .then(orders => console.log(orders)); // orders is the ARRAY, not a Promise wrapping an array

This flattening behavior is what makes sequential async steps composable — without it, chains would produce nested Promise<Promise<Promise<value>>> structures.

A .then() callback that returns a Promise gets auto-awaited, so the next .then() receives the unwrapped value
A .then() callback that returns a Promise gets auto-awaited, so the next .then() receives the unwrapped value

3Returning a Plain Value — Automatically Wrapped

js
Promise.resolve(1)
  .then(n => n + 1)       // returning a plain number — automatically becomes Promise.resolve(2)
  .then(n => console.log(n)); // 2

Every .then() callback's return value — Promise or plain value — is normalized the same way: the next .then() in the chain always receives the unwrapped, resolved value either way.

4Error Propagation Skips Directly to the Next .catch()

js
Promise.resolve()
  .then(() => { throw new Error("step 1 failed"); })
  .then(() => console.log("step 2 — SKIPPED"))   // skipped — error propagates past it
  .then(() => console.log("step 3 — SKIPPED"))   // skipped too
  .catch(e => console.log("caught:", e.message))  // "caught: step 1 failed"
  .then(() => console.log("step 4 — runs, chain has recovered")); // runs — .catch() "handles" the error

Once an error occurs, every subsequent .then() is skipped until a .catch() (or a .then(onFulfilled, onRejected) with a rejection handler) is reached. After that handler runs (without re-throwing), the chain is considered "recovered" and continues normally.

5Placing .catch() Strategically

js
// ONE catch at the end — handles errors from ANY step above it
step1().then(step2).then(step3).catch(handleAnyError);

// Per-step catch — recovers locally, chain continues with a fallback value
step1()
  .then(step2)
  .catch(err => {
    console.log("step2 failed, using fallback");
    return fallbackValue; // chain continues as if step2 had succeeded with this value
  })
  .then(step3);

6Chaining vs Nesting — the Anti-Pattern to Avoid

js
// BAD — nested .then() inside .then(), reintroducing "callback hell" with Promises
fetchUser(1).then(user => {
  fetchOrders(user.id).then(orders => {
    fetchOrderDetails(orders[0].id).then(details => {
      console.log(details);
    });
  });
});

// GOOD — flat chain, each step returns a Promise for the next .then() to pick up
fetchUser(1)
  .then(user => fetchOrders(user.id))
  .then(orders => fetchOrderDetails(orders[0].id))
  .then(details => console.log(details));

If you find yourself nesting .then() calls, you almost always meant to return the inner Promise and chain it flatly instead.

Under the Hood"Thenables" — Why Flattening Works on Non-Promise Objects Too▾

Section 2's flattening behavior is actually specified more broadly than "if you return a real Promise" — the underlying algorithm (spec name: Promise Resolve Thenable Job) checks for any object with a callable .then method at all, called a thenable, not specifically an instance of the built-in Promise class.

js
const fakePromiseLike = {
  then(onFulfilled, onRejected) {
    setTimeout(() => onFulfilled("resolved from a thenable"), 100);
  },
};

Promise.resolve(1)
  .then(() => fakePromiseLike) // NOT a real Promise instance — just an object with .then()
  .then(value => console.log(value)); // "resolved from a thenable" — flattening still works

Why this exists: interoperability with pre-native Promise libraries

Before native Promises existed, libraries like jQuery (Deferred) and early third-party Promise implementations (Bluebird, Q) all independently implemented Promise-like objects with a .then() method. Specifying the resolution algorithm around "does it have a callable .then" rather than "is it literally a Promise instance" meant native Promise chains could seamlessly consume values produced by any of these older libraries, without either side needing to know about the other's exact class.

The mechanism, precisely

When a .then() callback returns a value, the chain's resolution logic checks: does this value have a .then property that's callable? If yes, it doesn't treat the value as the final result — it calls that .then method itself, passing in functions that will resolve/reject the chain's own next Promise once the thenable eventually calls them. This is a real, recursive check too: if THAT .then call itself resolves with another thenable, the process repeats — which is also the specific mechanism that guards against a pathological thenable whose .then resolves with itself, a scenario the spec explicitly detects and rejects with a TypeError rather than looping forever.

Why this matters practically

Any object accidentally shaped like a thenable (has a .then property that happens to be a function, for reasons unrelated to Promises) will be treated as one by a Promise chain — a genuinely surprising source of bugs if, say, an object's .then property is coincidentally a business-logic method rather than Promise-resolution logic.

⚠️ Common Pitfalls

  1. Forgetting to return inside a .then() — if a callback doesn't return the inner Promise (or value), the next .then() receives undefined instead of waiting for it.
  2. Nesting instead of chaining — reproduces callback hell inside Promise syntax; always flatten by returning the inner Promise.
  3. Missing a final .catch() — an unhandled rejection anywhere in the chain will silently vanish (or trigger an unhandledRejection warning) if nothing downstream catches it.
  4. Assuming a .catch() mid-chain stops everything — it only stops error propagation; execution continues normally with whatever the .catch() handler returns.

📋 Quick Reference

text
.then(fn) returns a new Promise
returning a Promise from .then() → chain waits and flattens automatically
returning a plain value → wrapped automatically
throw inside .then() → skips to nearest .catch()
.catch() that doesn't re-throw → chain "recovers," continues normally

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1How does promise chaining work?core▾

Each .then() returns a new promise resolved with the callback's return value; returning a promise from a callback makes the chain wait for it. Errors propagate down the chain to the next .catch().

Follow-ups interviewers ask
  • What if you forget to return? The next step runs immediately with undefined.
Q2Where should .catch() go?core▾

Usually at the end to handle any error in the chain; intermediate catches can recover and continue (the chain continues with the catch's return value).

Follow-ups interviewers ask
  • Pitfall of catch in the middle? It swallows errors and later steps run with undefined.
Q3then(onFulfilled, onRejected) vs then().catch()?core▾

The second argument to then doesn't catch errors thrown in onFulfilled of the same then; a following .catch() does.

Follow-ups interviewers ask
  • Which to prefer? then().catch().
Q4What does finally do in a chain?senior▾

Runs cleanup regardless of outcome, doesn't receive the value, and passes through the original result or error (unless it throws).

Follow-ups interviewers ask
  • Use case? Hiding a spinner.
Q5How do you convert a chain to async/await?core▾

Replace each .then(x => …) with const x = await …, wrap in try/catch for .catch, and finally for .finally; the logic becomes linear.

Follow-ups interviewers ask
  • Any reason to keep chains? Short transformations and functional pipelines.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.