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.

JavaScriptintermediate
13 min read

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:

StateDescriptionCan Transition To
pendingIn flight, not yet settledfulfilled or rejected
fulfilledCompleted successfully, has a value(none, immutable)
rejectedFailed, 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:

javascriptjavascript
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:

javascriptjavascript
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 ReturnsNext .then receives
A plain value (42, "hello", {})That value
A resolved PromiseThe resolved value (unwrapped)
A rejected Promise.catch() fires with the rejection reason
Throws an error.catch() fires with the thrown error
Nothing (undefined)undefined
javascriptjavascript
// 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:

javascriptjavascript
// 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:

javascriptjavascript
// 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.

javascriptjavascript
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):

javascriptjavascript
// .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:

javascriptjavascript
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):

javascriptjavascript
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:

javascriptjavascript
// 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

javascriptjavascript
// 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:

javascriptjavascript
// 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

Rune AI

Key Insights

  • Always return your inner Promises: Forgetting return in a .then() handler breaks the chain, passing undefined to 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 await is equivalent to a .then() — the mental model of value flowing through a chain applies equally to async functions
RunePowered by Rune AI

Frequently Asked Questions

Can I chain .then() on a non-Promise value?

Not directly, but `Promise.resolve(value).then(...)` works for any value. You can also call `.then()` on any "thenable" — an object with a `.then` method. The Promise specification accepts any thenable, not just native Promises. This allows third-party Promise libraries to interoperate.

What is the difference between .then(a, b) and .then(a).catch(b)?

They differ when `a` throws. In `.then(a, b)`, if `a` (the fulfillment handler) throws, `b` (the rejection handler in the same `.then()`) does NOT catch it — the error propagates. In `.then(a).catch(b)`, if `a` throws, `.catch(b)` does catch it because it handles rejections from the previous `.then()` as a separate step.

Should I use .then() chains or async/await?

Use async/await for sequential operations — it is more readable. Use `.then()` directly when dealing with parallel operations via `Promise.all`, `Promise.race`, etc., or when working in contexts that do not support async/await (like `.map()` callbacks where you want to return Promises for `Promise.all` to consume).

How do I handle different errors differently in a chain?

Check the error type inside `.catch()`: ```javascript .catch((err) => { if (err instanceof NetworkError) return retryFetch(); if (err instanceof AuthError) return redirectToLogin(); throw err; // Unknown error, re-throw }) ```

Is there a performance cost to long chains?

Each `.then()` creates a new Promise object and schedules a microtask. For typical application code this is negligible. In performance-intensive loops, avoid creating long Promise chains where synchronous code would suffice.

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.