🔗 Promise Chaining and Composition
A single Promise is useful; the real power appears when you link them. Because every .then() returns a new Promise, you can turn a mountain of nested callbacks into a flat, top-to-bottom pipeline — and combine independent Promises to run work in parallel.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain why every Promise method returns a new Promise, and how that enables chaining
- Distinguish between returning a value and returning a Promise from a
.then() - Build a step-by-step data transformation pipeline instead of nested callbacks
- Apply advanced patterns: conditional branching, error recovery, and sequential processing
- Compose Promises with
Promise.allto rejoin parallel branches
Estimated Time: 35–45 minutes • Difficulty: Intermediate
Hands-on: Refactor a "pyramid of doom" into a flat chain, then add recovery.
In This Lesson
Why Chaining Works
Chaining rests on a single fact you learned last lesson: every Promise method — .then(), .catch(), .finally() — returns a brand-new Promise. That returned Promise settles based on what its handler returns. Attach another .then() to it and you have a chain; attach ten and you have a pipeline.
💡 Assembly-line analogy: Picture a factory conveyor belt. Each.then()is a workstation: it receives an item, does one job, and passes the result to the next station. If any station drops the item (throws or rejects), it slides straight to the error desk (.catch()) — skipping every station in between.
Promise] -->|.then| B[Step 2] B -->|.then| C[Step 3] C -->|.then| D[Step 4] D -->|.catch| E[Error desk]
The payoff is huge: operations read sequentially like synchronous code, data flows naturally from one step to the next, errors propagate automatically, and the dreaded "pyramid of doom" flattens into a clean vertical list.
Basic Chaining
The core rule is simple: return a value or a Promise from each .then(), and the next .then() receives it. Here we fetch a user, then use that result to fetch their posts:
fetch('/api/users/1')
.then((response) => {
console.log('Raw response received');
return response.json(); // returns a Promise
})
.then((user) => {
console.log('User:', user.name);
return fetch(`/api/users/${user.id}/posts`); // returns another Promise
})
.then((response) => response.json())
.then((posts) => {
console.log('Posts:', posts.length);
})
.catch((error) => {
console.error('Error somewhere in the chain:', error);
});
Notice there is only one .catch() at the bottom, yet it guards all four steps. That single point of error handling is one of the biggest wins over callbacks, where you had to check for an error at every nesting level.
Values vs. Promises
A .then() handler can return two kinds of things, and understanding the difference is the key to chaining:
📖 The rule
Return a plain value → it is automatically wrapped in a resolved Promise, and the next .then() receives that value right away.
Return a Promise → the chain pauses and waits for that Promise to settle before the next .then() runs, which then receives the settled value.
// Returning plain values
Promise.resolve(1)
.then((n) => { console.log('Step 1:', n); return n + 1; }) // 1
.then((n) => { console.log('Step 2:', n); return n * 2; }) // 2
.then((n) => console.log('Step 3:', n)); // 4
// Returning a Promise — the chain waits for it
Promise.resolve('u-123')
.then((userId) => fetch(`/api/users/${userId}`)) // returns a Promise
.then((response) => response.json()) // waits, then continues
.then((user) => console.log('User data:', user));
This automatic "unwrapping" of returned Promises is exactly what lets you flatten nested async calls into a single vertical chain.
Data Transformation Pipelines
Chains shine when you progressively reshape data through a series of steps — much like the "pipeline" concept in functional programming. Each step has one clear responsibility:
fetchUserData(userId)
.then((raw) => {
// Step 1 — normalize the raw shape
return {
id: raw.user_id,
name: raw.user_name,
email: raw.user_email,
isActive: raw.status === 'active',
};
})
.then((user) => {
// Step 2 — enrich with a second request
return fetchUserPreferences(user.id).then((prefs) => ({
...user,
preferences: prefs,
}));
})
.then((enriched) => {
// Step 3 — shape it for the view
return {
displayName: enriched.name,
contactInfo: enriched.email,
theme: enriched.preferences.theme ?? 'default',
language: enriched.preferences.language ?? 'en',
};
})
.then((viewModel) => renderUserProfile(viewModel))
.catch((error) => console.error('Pipeline failed:', error));
💡 One responsibility per step
Keeping each .then() focused on a single transformation makes the whole flow easy to read, test, and modify. If a step grows complicated, extract it into a named function and reference it: .then(normalizeUser).
Branching & Recovery
Conditional paths
You can branch inside a chain by returning different Promises — or throwing — based on a condition. All paths still funnel into the same .catch():
checkPermission(userId, 'read', documentId)
.then((allowed) => {
if (allowed) {
return fetchDocument(documentId); // permission path
}
throw new Error('Access denied'); // rejection path
})
.then((doc) => processDocument(doc))
.catch((error) => {
if (error.message === 'Access denied') {
showAccessDenied();
} else {
showGenericError();
}
});
Recovering from errors
A .catch() that returns a value turns a rejected chain back into a fulfilled one — perfect for fallbacks and graceful degradation. Here we fall back to cached data when the network fails:
fetchLatestData()
.catch((error) => {
console.warn('Live fetch failed, using cache:', error.message);
return getCachedData(); // recover — chain continues fulfilled
})
.then((data) => {
// Runs whether we used live or cached data
render(data);
})
.catch((error) => {
// Only reached if the cache ALSO failed
showFatalError(error);
});
✅ Catch position matters
A .catch() only handles errors from steps above it. Placing one mid-chain lets you recover and continue; placing one at the very end acts as a safety net for everything before it. Many robust chains use both.
Dynamic Sequential Chains
Sometimes the number of steps isn't known ahead of time — you have an array and must process each item after the previous finishes (for example, respecting an API's rate limit). The classic trick is reduce over a starting Promise.resolve():
function processInSequence(items, processItem) {
return items.reduce((chain, item) => {
return chain.then((results) => {
return processItem(item).then((result) => [...results, result]);
});
}, Promise.resolve([]));
}
// Each user is fetched only after the previous one completes
processInSequence([101, 102, 103], (id) => fetchUserData(id))
.then((all) => console.log(`Processed ${all.length} users`, all))
.catch((error) => console.error('Sequence failed:', error));
⚠️ Sequential is slower on purpose
This pattern deliberately runs requests one at a time. If the items are independent and order doesn't matter, prefer parallel execution with Promise.all (below) — it's dramatically faster. Reach for sequential only when each step depends on the last, or when you must throttle load.
Rejoining Parallel Branches with Promise.all
Chaining sequences work; composition coordinates independent work that can happen at the same time. Promise.all takes an array of Promises and returns one Promise that fulfills with an array of all their results — in order — or rejects the moment any single one rejects.
// Fetch three independent resources in parallel
Promise.all([
fetch('/api/users').then((r) => r.json()),
fetch('/api/posts').then((r) => r.json()),
fetch('/api/comments').then((r) => r.json()),
])
.then(([users, posts, comments]) => {
console.log(`${users.length} users, ${posts.length} posts, ${comments.length} comments`);
return combine(users, posts, comments);
})
.catch((error) => {
console.error('At least one request failed:', error);
});
A common real-world shape combines both ideas: run a chain up to a shared point, split into independent branches, then rejoin them with Promise.all:
const profile = fetchUserProfile(userId); // one shared Promise
const activity = profile.then((p) => analyzeActivity(p.id));
const recos = profile.then((p) => getRecommendations(p.id));
Promise.all([profile, activity, recos])
.then(([p, activityReport, recommendations]) => {
return buildDashboard(p, activityReport, recommendations);
})
.then(render)
.catch((error) => console.error('Dashboard failed:', error));
💡 More combinators are coming
Promise.all is the workhorse, but it "fails fast": one rejection sinks the whole batch. The next lesson covers Promise.race, Promise.allSettled, and Promise.any, which handle timeouts, partial success, and first-success scenarios.
Hands-on Exercise
🏋️ Flatten a pyramid of doom
Objective: Convert deeply nested callback-style Promises into a flat chain, then add error recovery.
Starting point (the anti-pattern):
fetch('/api/user')
.then((res) => res.json())
.then((user) => {
fetch(`/api/user/${user.id}/posts`)
.then((res) => res.json())
.then((posts) => {
fetch(`/api/user/${user.id}/followers`)
.then((res) => res.json())
.then((followers) => {
console.log(user, posts, followers);
});
});
});
Instructions:
- Rewrite it so there is no nesting — each
.then()returns the next Promise. - Carry the earlier values forward (you'll need
user.idin later steps). - Add a single
.catch()at the end that logs the error.
💡 Hint
Store values you'll need later in variables declared outside the chain (e.g. let user, posts;), or pass an accumulating object down each step. Always return the fetch() so the next .then() waits for it.
✅ Solution
let user, posts;
fetch('/api/user')
.then((res) => res.json())
.then((u) => {
user = u;
return fetch(`/api/user/${user.id}/posts`);
})
.then((res) => res.json())
.then((p) => {
posts = p;
return fetch(`/api/user/${user.id}/followers`);
})
.then((res) => res.json())
.then((followers) => {
console.log(user, posts, followers);
})
.catch((error) => {
console.error('Chain failed:', error);
});
🎯 Quick Quiz
Question 1: What makes Promise chaining possible in the first place?
Question 2: Inside a .then() you return fetch(url) (a Promise). What does the next .then() receive?
Question 3: You have three independent API calls and need all of their results before rendering. Which is the best fit?
Summary & Quiz
🎉 Key Takeaways
- Chaining works because every Promise method returns a new Promise.
- Return a value to pass it straight on; return a Promise and the chain waits for it to settle.
- Model workflows as a flat pipeline of single-responsibility steps, with one
.catch()at the end. - A
.catch()that returns a value recovers the chain; its position controls what it guards. - Use
reducefor sequential work, andPromise.allto run independent Promises in parallel and rejoin them.
📚 Further Reading
🚀 What's Next?
You've flattened chains and touched Promise.all. Next we'll study the full family of Promise static methods — resolve, reject, all, race, allSettled, and any — and when to reach for each.
🎉 Great progress!
Nested callbacks are behind you. Let's meet the Promise combinators.