JavaScript Promise Chaining: A Complete Guide
Promise chaining links multiple .then() calls into a flat pipeline instead of nested callbacks. Learn how values and errors flow through a chain.
JavaScript promise chaining is the pattern of linking multiple .then() calls so that each asynchronous step starts after the previous one finishes. Instead of nesting callbacks inside callbacks, you create a flat pipeline where each step receives the result of the step before it.
A Promise represents a value that is not available yet. Calling then on a promise registers a callback that runs when the promise resolves. The key detail that makes chaining work is that a then call itself returns a new promise, so you can chain another then right after it.
function getUser(id) {
return new Promise((resolve) => {
setTimeout(() => resolve({ id, name: "Taylor" }), 500);
});
}
function getPosts(user) {
return new Promise((resolve) => {
setTimeout(() => resolve([`${user.name}'s post 1`]), 500);
});
}Both helpers simulate a real API call by resolving after a short delay instead of returning a value right away. Chain them together and each step waits for the one before it to finish.
getUser(1)
.then((user) => {
console.log("Got user:", user.name);
return getPosts(user);
})
.then((posts) => {
console.log("Got posts:", posts);
});Running this prints the user first, then the posts, roughly one second later once both delays have passed. Nothing about the syntax forces that order. The order comes entirely from the second then waiting for the promise returned by the first.
Got user: Taylor
Got posts: ["Taylor's post 1"]The return value of one callback becomes the input of the next. This flat structure replaces what would otherwise be deeply nested callbacks, one inside another.
How then returns a new promise
Every then call creates and returns a brand-new promise. What that new promise resolves to depends on what the callback returns.
The diagram shows the value moving forward one step at a time. Each callback receives the resolved value of the promise before it, and the value it returns becomes the resolved value of the next promise in line.
Promise.resolve(10)
.then((num) => {
console.log(num); // 10
return num * 2;
})
.then((num) => {
console.log(num); // 20
return num + 5;
})
.then((num) => {
console.log(num); // 25
});Three then calls, three values flowing forward, no callbacks nested inside other callbacks. Each step transforms the number and passes it along, and the comments show exactly what each step logs.
Returning a promise from then
When a then callback returns a promise instead of a plain value, the chain does not move on right away. It waits for that inner promise to settle first, then passes the resolved value to the next step.
function delayValue(value, ms) {
return new Promise((resolve) => {
setTimeout(() => resolve(value), ms);
});
}This helper stands in for any operation that finishes after a delay, such as a network request. Chaining two calls to it shows the waiting behavior directly.
delayValue("step 1", 400)
.then((result) => {
console.log(result);
return delayValue("step 2", 400);
})
.then((result) => {
console.log(result);
});The second value only logs after its own 400ms delay finishes, not immediately after the first one. The chain pauses at each step until the returned promise settles, so the total time is the sum of both delays rather than the longer of the two.
step 1 (after 400ms)
step 2 (after another 400ms)Returning a promise inside then is the standard way to sequence async operations without nesting. If you are new to how JavaScript schedules delayed callbacks, see JavaScript setTimeout Behavior: Complete Guide for the timer mechanics used in these examples.
Error handling with .catch()
A .catch() placed at the end of a chain catches any rejection from any then above it. The error skips all remaining then handlers and jumps straight to the nearest catch.
function fetchData(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id < 0) {
reject(new Error("Invalid ID"));
} else {
resolve({ id, data: "Some data" });
}
}, 500);
});
}This helper rejects when the ID is negative and resolves otherwise, which lets the next example show both paths through a chain.
fetchData(-1)
.then((result) => {
console.log("Processing:", result.data);
return result.data.toUpperCase();
})
.then((processed) => {
console.log("Done:", processed);
})
.catch((error) => {
console.error("Failed:", error.message);
});Because the call rejects with a negative ID, neither then callback runs at all. The error skips straight past both of them and lands directly in the catch handler at the end of the chain.
Failed: Invalid IDYou can also place a catch in the middle of a chain to recover from an error and let the chain continue, instead of ending it.
fetchData(42)
.then((result) => {
throw new Error("Something went wrong during processing");
})
.catch((error) => {
console.warn("Recovered from:", error.message);
return { id: 42, data: "Fallback data" };
})
.then((result) => {
console.log("Still got:", result.data);
});The catch here returns a fallback value instead of re-throwing, so the chain keeps going as if that step had succeeded.
Recovered from: Something went wrong during processing
Still got: Fallback dataThis is the promise equivalent of a try/catch block that recovers from an error and keeps running instead of stopping the whole program.
Finally: run code regardless of outcome
A .finally() callback runs whether the chain resolves or rejects. It does not receive the result or the error, and whatever it returns does not affect the value passed down the chain.
function loadData() {
return fetchData(1)
.then((result) => console.log("Data:", result.data))
.catch((error) => console.error("Error:", error.message))
.finally(() => console.log("Loading finished"));
}
loadData();Since fetchData(1) resolves, the then callback logs the data first and the catch callback is skipped entirely, but finally still runs afterward.
Data: Some data
Loading finishedFinally is ideal for cleanup work that must happen either way: hiding a loading spinner, closing a connection, or resetting UI state. If fetchData(1) had rejected instead, the catch callback would have logged the error, and finally would still run afterward and print the same "Loading finished" message. That is the point of finally: it does not care which path the chain took, only that the chain is done.
The sequence matters: catch placement
Where you put a catch in a chain changes which errors it actually handles. Each catch only sees errors from the steps above it, not the ones after it.
Promise.resolve("start")
.then((val) => {
console.log("Step 1:", val);
throw new Error("Step 1 failed");
})
.catch((err) => {
console.log("Caught:", err.message);
return "recovered";
})
.then((val) => {
console.log("Step 2:", val);
throw new Error("Step 2 failed");
})
.catch((err) => console.log("Caught:", err.message));The first catch handles the error thrown in Step 1 and returns a recovered value, so Step 2 runs normally before throwing its own error, which the second catch handles separately.
Step 1: start
Caught: Step 1 failed
Step 2: recovered
Caught: Step 2 failedA single catch at the very end would catch everything but give you less control over recovery. Multiple catch calls let you handle specific failures and keep the rest of the chain running.
This placement rule is the same reason a .catch() in the middle of a chain, as shown in the error handling section above, does not affect errors thrown by steps that come after it. Each catch is a checkpoint for the chain above it, not a safety net for the entire chain.
Common promise chaining mistakes
Forgetting to return the promise. If a then callback calls an async function but does not return it, the next then does not wait for it and receives undefined instead.
// Mistake: getPosts returns a promise, but we do not return it
getUser(1).then((user) => {
getPosts(user); // No return!
}).then((posts) => {
console.log(posts); // undefined
});
// Correct: return the promise
getUser(1)
.then((user) => getPosts(user))
.then((posts) => console.log(posts)); // Array of postsThe mistake version calls getPosts but throws away its result, so the second then has nothing to wait for. The corrected version returns getPosts directly, which keeps the chain connected.
Nesting then instead of chaining. Calling then inside another then recreates the same callback pyramid that chaining is meant to avoid.
// Mistake: nested then
getUser(1).then((user) => {
getPosts(user).then((posts) => {
console.log(posts);
});
});
// Correct: flat chain
getUser(1)
.then((user) => getPosts(user))
.then((posts) => console.log(posts));Both versions produce the same result, but the flat chain reads top to bottom instead of growing deeper with every added step.
Assuming order when multiple promises are created at once. If you create several promises at the same time and chain them, all of those promises start running immediately, not one after another. To run work in a strict order, create each promise inside the then callback of the one before it. For the async/await equivalent of this pattern, see JavaScript Async Await Guide: Complete Tutorial.
Swallowing errors silently. A catch handler that does nothing with the error hides real bugs from you. At minimum, log the error, or re-throw it if the caller is the one who should actually handle it.
Rune AI
Key Insights
- Every .then() returns a new promise, enabling flat chaining without nesting.
- Whatever you return from a .then() callback becomes the resolved value of the next promise.
- A .catch() handles rejections from any preceding step in the chain.
- .finally() runs regardless of success or failure and does not receive the result.
- Returning a promise inside .then() flattens it automatically, preventing nested chains.
Frequently Asked Questions
Does .then() always return a promise?
What is the difference between .then() and .catch()?
Can I have multiple .catch() calls in one chain?
Conclusion
Promise chaining is the pattern that makes asynchronous code in JavaScript readable. Every .then() returns a new promise, values flow forward, and errors bubble to the nearest .catch(). Once you understand that a chain is just a series of transformations, you can structure any async workflow as a clean pipeline.
More in this topic
Using Decorators for Logging in JS Architecture
Learn how JavaScript decorators wrap class methods to add logging without touching the original code, including setup with Babel and how execution order works.
JavaScript While Loop Explained: A Complete Guide
A while loop repeats code as long as a condition stays true. Learn the syntax, see practical examples, and understand when a while loop is the right choice over a for loop.
JavaScript Loops Tutorial: for, while, do while
Loops let JavaScript repeat code without writing it twice. Learn the three core loop types, what each one does, and when to choose one over another.