Promise Chaining
Promise chaining is what turns a single promise into a readable, linear sequence of asynchronous steps. Because every call to then returns a brand-new promise, you can attach another then to the result and keep going—each handler receiving the output of the one before it. Whatever you return from a handler becomes the value the next handler sees, and a single .catch() at the end can handle a failure from anywhere in the chain. Master this and you avoid the deeply nested “pyramid of doom” that plagues raw callbacks.
How a chain is built
Each then, catch, and finally produces a new promise. That returned promise settles based on what its handler does, so you read a chain top-to-bottom like a list of steps. The value flows downward: the resolved value of one link becomes the input to the next.
Promise.resolve(2)
.then((n) => n + 3)
.then((n) => n * 10)
.then((n) => console.log("result:", n));
Output:
result: 50
Notice there is no nesting. Each step is a flat, sibling then rather than a callback inside a callback.
Returning values vs returning promises
A handler can return two kinds of things, and the chain treats them differently:
| You return | What the next handler receives |
|---|---|
A plain value (number, object, etc.) | That value, wrapped in a fulfilled promise automatically. |
| A promise (or any thenable) | The settled value of that promise—the chain waits for it. |
Nothing (undefined) | undefined. |
| A thrown error | Nothing—the chain jumps to the next rejection handler. |
This is the key insight: when you return a promise from inside then, the chain does not pass the promise object along. It adopts that promise and waits for it to resolve, then hands the resolved value to the next link. This automatic unwrapping is called flattening, and it is what lets you sequence genuinely asynchronous steps.
function getUser(id) {
return Promise.resolve({ id, name: "Ada" });
}
function getPosts(userId) {
return Promise.resolve([`post-${userId}-a`, `post-${userId}-b`]);
}
getUser(1)
.then((user) => {
console.log("user:", user.name);
return getPosts(user.id); // returns a promise
})
.then((posts) => {
// chain waited for getPosts to resolve; posts is the array, not a promise
console.log("posts:", posts);
});
Output:
user: Ada
posts: [ 'post-1-a', 'post-1-b' ]
Without the
returninside the firstthen, the second handler would receiveundefinedand the chain would not wait forgetPosts. Forgetting to return a promise is the single most common chaining bug.
Error propagation through the chain
A rejection skips every subsequent then and travels down to the first catch (or rejection handler) it finds. You do not need a catch after each step—one terminal handler covers the whole chain. The same applies to synchronous exceptions thrown inside a handler: they reject the returned promise automatically.
Promise.resolve("start")
.then((v) => {
console.log("step 1:", v);
throw new Error("step 2 failed");
})
.then((v) => {
console.log("step 3 (skipped):", v);
})
.catch((err) => {
console.log("caught:", err.message);
});
Output:
step 1: start
caught: step 2 failed
You can also recover mid-chain. A catch that returns a value produces a fulfilled promise, so the chain continues with normal handlers afterward.
Promise.reject(new Error("network down"))
.catch((err) => {
console.log("recovering from:", err.message);
return "cached data"; // recovery value
})
.then((data) => console.log("continuing with:", data));
Output:
recovering from: network down
continuing with: cached data
Place catch thoughtfully: a catch in the middle handles only errors from links above it, while a final catch is your safety net for the entire chain.
Avoiding the nested-then anti-pattern
Returning a promise enables flattening, but it is easy to forget and accidentally nest then calls inside one another. This recreates the very callback pyramid promises were meant to eliminate—errors no longer propagate cleanly, and the indentation creeps rightward.
// Anti-pattern: nested thens (do NOT do this)
getUser(1).then((user) => {
getPosts(user.id).then((posts) => {
getComments(posts[0]).then((comments) => {
console.log(comments); // pyramid of doom returns
});
});
});
Flatten it by returning each promise and chaining at the same level:
// Good: flat chain, single error handler
getUser(1)
.then((user) => getPosts(user.id))
.then((posts) => getComments(posts[0]))
.then((comments) => console.log(comments))
.catch((err) => console.error("Pipeline failed:", err.message));
When you need a value from an earlier step and a later step (the classic “I still need user down here” problem), either widen the returned object or reach for async/await, which makes shared scope trivial.
// Carry context forward by returning a combined object
getUser(1)
.then((user) => getPosts(user.id).then((posts) => ({ user, posts })))
.then(({ user, posts }) =>
console.log(`${user.name} has ${posts.length} posts`)
);
If you find yourself nesting to share variables, that is the strongest signal to switch the function to
async/await—the readability win is large.
Best Practices
- Always
returnthe value or promise you want the next handler to receive; a missingreturnsilently passesundefined. - Keep each
thenfocused on one step and chain them at the same indentation level—never nesttheninsidethen. - Attach exactly one terminal
.catch()per chain so a single failure path covers every step. - Use a mid-chain
catchonly when you intend to recover and continue with a fallback value. - Throw
Errorobjects (not strings) inside handlers so stack traces survive propagation. - Reach for
async/awaitwhen steps need to share earlier variables—it removes the need to thread context through return values.