Skip to main content

🔗 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.all to 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.
graph LR A[Initial
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);
  });
graph TD A[Start] --> B[Fetch users] A --> C[Fetch posts] A --> D[Fetch comments] B --> E{{Promise.all}} C --> E D --> E E --> F[Combine all data]

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:

  1. Rewrite it so there is no nesting — each .then() returns the next Promise.
  2. Carry the earlier values forward (you'll need user.id in later steps).
  3. 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 reduce for sequential work, and Promise.all to 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 methodsresolve, 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.