JavaScript Promise Chaining: A Complete Guide
Master JavaScript Promise chaining with .then(), .catch(), and .finally(). Learn how values flow through chains, common mistakes to avoid, and patterns for sequential and parallel async operations.
Promise chaining is the mechanism that lets you sequence async operations without nesting. Instead of callbacks inside callbacks, you write a flat sequence of .then() calls where each step receives the previous step's result. Understanding exactly how values flow through a chain — and what common mistakes break it — is essential for writing reliable async JavaScript.
Promise States
Before chaining makes sense, you need to understand the three states a Promise can be in:
| State | Description | Can Transition To |
|---|---|---|
pending | In flight, not yet settled | fulfilled or rejected |
fulfilled | Completed successfully, has a value | (none, immutable) |
rejected | Failed, has a reason/error | (none, immutable) |
Once a Promise settles (either fulfills or rejects), its state is permanent. This immutability is what makes chaining predictable.
The .then() Method
.then(onFulfilled, onRejected) registers callbacks to run when the Promise settles:
const p = new Promise((resolve, reject) => {
setTimeout(() => resolve(42), 100);
});
p.then(
(value) => console.log("Fulfilled:", value), // 42
(reason) => console.error("Rejected:", reason)
);Critical rule: .then() always returns a NEW Promise. This is what enables chaining.
The value of the new Promise depends on what the handler returns:
const p = Promise.resolve(1);
p
.then((n) => n + 1) // Returns 2 (plain value → fulfilled with 2)
.then((n) => n * 10) // Returns 20
.then((n) => {
console.log(n); // 20
// Returns undefined → next .then gets undefined
})
.then((n) => {
console.log(n); // undefined
});Value Flow Rules
Understanding how the returned value affects the chain is the key to mastering chaining:
| Handler Returns | Next .then receives |
|---|---|
A plain value (42, "hello", {}) | That value |
| A resolved Promise | The resolved value (unwrapped) |
| A rejected Promise | .catch() fires with the rejection reason |
| Throws an error | .catch() fires with the thrown error |
| Nothing (undefined) | undefined |
// Example of each case
Promise.resolve("start")
.then((v) => 42) // plain value
.then((v) => Promise.resolve(v * 2)) // resolved Promise (unwrapped to 84)
.then((v) => Promise.reject("oops")) // rejected Promise → jumps to catch
.then((v) => console.log("Never runs"))
.catch((err) => `Caught: ${err}`) // "Caught: oops"
.then((v) => console.log(v)); // "Caught: oops"The Most Common Chaining Mistake
Forgetting to return the inner Promise:
// BUG: Not returning the promise
getUserById(1)
.then((user) => {
getPostsForUser(user.id); // Missing return!
// Returns undefined, not the posts Promise
})
.then((posts) => {
console.log(posts); // undefined — not the posts!
});
// CORRECT: Return the inner Promise
getUserById(1)
.then((user) => {
return getPostsForUser(user.id); // ← return
})
.then((posts) => {
console.log(posts); // The actual posts
});
// Also correct (arrow function implicit return):
getUserById(1)
.then((user) => getPostsForUser(user.id)) // ← implicit return
.then((posts) => console.log(posts));Passing Data Through the Chain
When multiple steps need access to an earlier result, there are two patterns:
// Pattern 1: Nested .then() (limited nesting is fine for accessing parent scope)
getUser(userId)
.then((user) => {
return getPosts(user.id).then((posts) => ({ user, posts }));
// ↑ one level of nesting to attach user to result
})
.then(({ user, posts }) => {
console.log(`${user.name} has ${posts.length} posts`);
});
// Pattern 2: Intermediate variable via closure or async/await
let cachedUser;
getUser(userId)
.then((user) => {
cachedUser = user; // Store for later
return getPosts(user.id);
})
.then((posts) => {
console.log(`${cachedUser.name} has ${posts.length} posts`);
});
// Pattern 3 (best): async/await — no workaround needed
async function loadUserPosts(userId) {
const user = await getUser(userId);
const posts = await getPosts(user.id);
console.log(`${user.name} has ${posts.length} posts`);
}Error Handling in Chains
.catch(handler) is shorthand for .then(undefined, handler). It handles any rejection that propagates to it from earlier in the chain.
fetchUser(1)
.then((user) => fetchPosts(user.id)) // Any error here...
.then((posts) => processResults(posts)) // ...or here...
.catch((error) => { // ...lands here
console.error("Something failed:", error.message);
});Where to Place .catch()
Placement matters because .catch() also returns a new Promise (recovered from the error):
// .catch() in the middle — recovers and continues the chain
fetchDataPrimary()
.catch(() => fetchDataFallback()) // If primary fails, try fallback
.then((data) => processData(data)) // Runs whether primary or fallback succeeded
.catch((err) => handleFatalError(err)); // Only fires if fallback also failed
// Multiple .catch() locations for stage-specific recovery
fetchConfig()
.catch(() => defaultConfig) // If config fails, use default, continue
.then((config) => initApp(config))
.catch((err) => { // If initApp fails (not config), handle here
showInitError(err);
});Errors After .catch()
After a .catch() handles an error, the chain continues normally (fulfilled) unless the handler itself throws:
Promise.reject("error")
.catch((err) => {
console.log("Handled:", err); // "Handled: error"
return "recovered value"; // Chain is now fulfilled
})
.then((v) => console.log(v)); // "recovered value"
Promise.reject("error")
.catch((err) => {
throw new Error("Cannot recover"); // Re-throw → chain is still rejected
})
.catch((err) => console.error(err.message)); // "Cannot recover".finally()
.finally(handler) runs regardless of whether the Promise fulfilled or rejected. It does not receive a value and cannot change the outcome (unless it throws):
let loadingSpinner;
function fetchWithSpinner(url) {
loadingSpinner = showSpinner();
return fetch(url)
.then((res) => res.json())
.catch((err) => {
notifyUser("Fetch failed");
throw err; // Re-throw so caller knows about the error
})
.finally(() => {
loadingSpinner.hide(); // Always runs — success or failure
});
}.finally() is ideal for cleanup: hiding loading states, closing connections, clearing timers. It passes through the settled value/reason unchanged to the next handler.
Sequential vs Parallel Chaining
.then() chaining is inherently sequential:
// Sequential: each awaits the previous
fetchUser(1)
.then((user) => fetchPosts(user.id)) // Starts after fetchUser completes
.then((posts) => processResults(posts));
// Parallel: start all, wait for all
Promise.all([
fetchUser(1),
fetchCategories(),
fetchSettings(),
]).then(([user, categories, settings]) => {
initApp(user, categories, settings);
});
// Mixed: parallel fetch, then sequential processing
Promise.all([fetchUser(1), fetchPermissions(1)])
.then(([user, perms]) => {
if (!perms.canEdit) throw new Error("No permission");
return updateUser(user.id, newData);
})
.then((updated) => notifySuccess(updated))
.catch((err) => notifyError(err));For more on parallel patterns, see Promise.all and related methods.
Chain Anti-Patterns
// ANTI-PATTERN 1: Nested .then() without reason (Promise hell)
fetchA()
.then((a) => {
return fetchB(a).then((b) => { // Unnecessary nesting
return fetchC(b).then((c) => { // No need to access a or b below
return process(c);
});
});
});
// BETTER: flat chain
fetchA()
.then((a) => fetchB(a))
.then((b) => fetchC(b))
.then((c) => process(c));
// ANTI-PATTERN 2: Creating new Promise unnecessarily (Promise constructor anti-pattern)
function readUserBad(id) {
return new Promise((resolve, reject) => {
fetchUser(id) // fetchUser already returns a Promise!
.then(resolve)
.catch(reject);
});
}
// BETTER: just return the existing Promise
function readUser(id) {
return fetchUser(id);
}
// ANTI-PATTERN 3: Ignoring the returned Promise
doSomethingAsync(); // Fire and forget — unhandled rejections are invisible bugs
doSomethingElse(); // Runs concurrently, not sequentially
// BETTER: return or await the Promise
async function sequence() {
await doSomethingAsync();
doSomethingElse();
}async/await Is Promise Chaining
async/await is syntactic sugar over Promise chaining. Understanding .then() chains helps you understand what await does:
// Promise chain version
function processOrder(orderId) {
return getOrder(orderId)
.then((order) => validateOrder(order))
.then((order) => chargeCustomer(order))
.then((receipt) => sendConfirmation(receipt))
.catch((err) => handleOrderError(err));
}
// async/await version (identical behavior)
async function processOrder(orderId) {
try {
const order = await getOrder(orderId);
const validated = await validateOrder(order);
const receipt = await chargeCustomer(validated);
await sendConfirmation(receipt);
} catch (err) {
handleOrderError(err);
}
}Both execute identically. The event loop processes .then() callbacks as microtasks, and await produces the same microtask scheduling.
Rune AI
Key Insights
- Always return your inner Promises: Forgetting
returnin a.then()handler breaks the chain, passingundefinedto the next step instead of the async result - Value transformation is first-class: Returning a plain value from
.then()wraps it in a fulfilled Promise; returning a Promise unwraps it, enabling step-by-step data transformation - One .catch() handles all preceding rejections: Place it at the end of the chain to catch errors from any step, or in the middle to recover and continue
- .finally() is for cleanup, not transformation: It runs regardless of outcome, passes through the original settled value unchanged, and is ideal for hiding spinners or releasing resources
- async/await is the same thing: Every
awaitis equivalent to a.then()— the mental model of value flowing through a chain applies equally to async functions
Frequently Asked Questions
Can I chain .then() on a non-Promise value?
What is the difference between .then(a, b) and .then(a).catch(b)?
Should I use .then() chains or async/await?
How do I handle different errors differently in a chain?
Is there a performance cost to long chains?
Conclusion
Promise chaining flattens sequential async operations into a readable horizontal sequence. .then() always returns a new Promise, enabling the chain; the value in the next .then() depends on what the previous handler returns. Errors propagate until caught by .catch(). .finally() handles cleanup. Understanding these mechanics is essential for writing correct async JavaScript and forms the basis for understanding how to handle rejections and parallel Promise patterns.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.