Command Palette

Search for a command to run...

Hectal
PHASE 10Intermediate ES2015+ ~20 min· topic 6 of 8

Topic 10.6

Chaining and Error Handling with Promises

In one line

Every then, catch and finally returns a new promise, so you can chain steps: what a callback returns becomes the next step's value (and a returned promise is waited for), a throw skips ahead to the nearest catch, and a rejection nobody handles is reported as an unhandled rejection.

Think of it like this

A relay race. Each runner takes the baton from the one before, runs their leg and hands the baton on. If a runner hands over nothing, the next runner starts empty-handed. If a runner falls (an error), the baton skips straight to the team's first-aid tent (the catch), and the runners in between never run. Once first aid patches things up, the race can carry on.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Promise chain
Several then/catch/finally calls in a row, each waiting for the previous step.
Return value (in a chain)
What a then callback returns; it becomes the value the next step receives.
Propagation
An error travelling down the chain, skipping steps, until something handles it.
Recover
Handle an error in catch and return a usable value, so the chain continues normally.
Re-throw
Throw the error again from a catch so an outer handler deals with it.
Unhandled rejection
A rejected promise that no catch ever handles; the browser reports it as an error.

Step by step

01Each step's return feeds the next

then returns a new promise, fulfilled with whatever your callback returns.

chain.jswhole filejavascript
Promise.resolve(2)
  .then((n) => n * 10)       // 20
  .then((n) => n + 1)        // 21
  .then((n) => console.log(n)); // prints 21

02Return a promise to wait for it

If a step returns a promise, the chain waits until that promise settles and passes on its result. This is how you run async steps one after another, flat instead of nested.

flat.jswhole filejavascript
loadUser(7)
  .then((user) => loadOrders(user.id))      // returns a promise
  .then((orders) => loadDetails(orders[0]))  // waits, then returns another
  .then((details) => show(details))
  .catch((err) => showError(err));          // one catch for every step

03A throw skips to the next catch

When any step throws or returns a rejected promise, the following then callbacks are skipped until a catch.

A throw skips to the next catchdiagram
Rendering diagram…

04Recover or re-throw

Return a value from catch to recover. Throw again if this step can't really fix the problem, so a later catch (or the unhandled-rejection report) sees it.

recover.jswhole filejavascript
loadSettings()
  .catch((err) => {
    if (err.message === "Not found") return DEFAULT_SETTINGS; // recover
    throw err;                                             // can't fix: pass it on
  })
  .then(applySettings);

05End every chain with a catch

Either handle errors at the end of the chain, or return the chain from your function so the caller can. A promise that rejects with nobody listening shows up as Uncaught (in promise).

Try it yourself

  1. 1

    Return from step 3

    In the first example, add return "done"; at the end of step 3. Predict what step 4 logs.

  2. 2

    Re-throw from catch

    In the third example, change chain A's catch to throw err; instead of returning the fallback. Predict which line for chain A disappears and what new red line appears.

  3. 3

    Handle the rejection

    In the last example, add a .catch(...) to the saveDraft() chain that logs Could not save: Server said 500. Predict the full output first.

Code & diagrams

Returning values and promises through a chain New tab

Step 3 received `21`, not a promise: returning a promise from `then` makes the chain wait for it and unwrap its value.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

step 1 got 2
step 2 got 20
step 3 got 21 after waiting
step 4 got undefined because step 3 returned nothing
Forgetting return breaks the chain New tab

The first chain didn't wait for its inner request, so it finished early with `undefined`. In real code that's a race: data shows up "sometimes".

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Without return: undefined
With return: 5
Errors skip ahead, catch recovers, then(ok, fail) misses bugs New tab

The three chains run side by side, one microtask step at a time, so their lines interleave. Chain A's error has to pass through one extra skipped `then` first, so A prints last. Follow each letter on its own.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

B: catch also sees bugs: TypeError
C: only a later catch sees it: thrown in the success handler
A: caught step 2 failed
A: continues with fallback data
An unhandled rejection New tab

The error is reported, but the user sees nothing. Add `.catch((err) => console.log("Could not save:", err.message))` and the red line disappears.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Saving the draft…
The rest of the page keeps working
Uncaught (in promise) Error: Server said 500

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Nest instead of chain

Write loadUser(7).then((user) => { loadOrders(user.id).then((orders) => { show(orders); }); }).catch(showError); and make loadOrders reject.

terminal
$ Console
── what you'll see ──
Uncaught (in promise) Error: Orders service down

Break #2

Swallow errors with an empty catch

End a chain with .catch(() => {}) to make the red errors go away.

terminal
$ the page shows
── what you'll see ──
Nothing. The data never appears and there's no error anywhere to explain why.

Myth vs fact

Myth

then changes the original promise.

Fact

It returns a new promise. The original keeps its own value; calling p.then(a) and p.then(b) separately gives two independent branches that both receive p's value.

Myth

A catch at the end of a chain catches errors from inner, nested promises too.

Fact

Only if those inner promises are returned into the chain. Unreturned inner promises are separate chains with their own unhandled rejections.

Myth

An unhandled rejection stops the script like an uncaught exception.

Fact

In the browser, other code keeps running; the error is only reported. That's exactly why it's dangerous: the failure is easy to miss. Node.js, however, exits by default.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    The browser decides a rejection is unhandled after the microtask queue drains with no handler attached. If a handler is attached later, a rejectionhandled event fires. Listening to unhandledrejection (and calling event.preventDefault() to silence the default console message) is how error trackers capture these failures.

  • ▸

    Each then costs one or more microtask turns, so long chains of trivial steps can be noticeably slower than one step doing all the work. When steps are synchronous, combine them.

  • ▸

    Errors thrown inside promise callbacks keep their stack trace, and V8's async stack traces stitch together the frames across await boundaries in DevTools, so you can see which async caller started the failing step.

  • ▸

    A rejection reason can be anything; libraries sometimes reject with strings or plain objects. Defensive catch code checks err instanceof Error before reading err.message, and ES2022's new Error(msg, { cause: err }) lets you wrap a low-level error in a clearer one without losing the original.

Remember this

  1. 1

    then returns a new promise. That's what makes a chain possible: p.then(a).then(b).then(c). Each step waits for the one before it. The new promise settles according to what the callback does: return a value and it fulfils with that value; return a promise and it waits for that promise and copies its result; throw and it rejects with the thrown error.

  2. 2

    Returning matters. If a step starts async work but doesn't return its promise, the chain doesn't wait for it: the next step runs at once with undefined. This is the most common promise bug. With arrow functions, .then((user) => loadOrders(user.id)) returns implicitly; with braces you must write return loadOrders(user.id);.

  3. 3

    A rejection travels down the chain, skipping every then success callback, until it reaches a catch (or a then with a second, error callback). So one catch at the end handles errors from every step above it, including bugs (a TypeError thrown inside a then). This replaces the repeated if (err) checks of callback hell.

  4. 4

    A catch handles the error: the promise it returns fulfils with whatever the catch returns, so the chain continues normally after it. Use that to recover (return a default value). If you can't handle the error there, re-throw it (throw err;) so it keeps travelling to an outer catch. A catch in the middle of a chain only protects the steps above it.

  5. 5

    p.then(onOk, onFail) is not the same as p.then(onOk).catch(onFail). In the first form, onFail handles errors from p only, not errors thrown by onOk. In the second, catch also catches mistakes inside onOk. Prefer .then(...).catch(...) unless you specifically need the first.

  6. 6

    A rejected promise with no handler is an unhandled rejection. The browser reports it in the console as Uncaught (in promise) Error: ... and fires an unhandledrejection event on window, which error-tracking tools listen to. Your other code keeps running, but the failure is otherwise silent to the user. In Node.js (since version 15), an unhandled rejection crashes the process by default. Every chain should end in a catch, or be returned to a caller who handles it.

Explain it without notes

01

What does then return, and how is its value decided?

02

How do errors travel through a promise chain, and how do you recover from one?

03

What's the difference between p.then(ok, fail) and p.then(ok).catch(fail)?

04

What is an unhandled rejection, how is it reported, and how do you avoid it?

Practice

01

Rewrite the callback-hell example from Topic 10.4 as a flat chain, using later(value) that returns a promise fulfilled after 50 ms.

02

Write a chain that starts from Promise.reject(new Error("offline")), recovers with the value "cached copy" in a catch, and logs Showing: cached copy.

Trade-offs

  • ↔

    One catch at the end is simple and catches everything, but can't tell which step failed or recover per step; a catch after a specific step can recover locally but must re-throw anything it can't handle.

  • ↔

    Recovering with a fallback keeps the page working but can hide real outages; re-throwing surfaces problems but needs a handler higher up. Log or report whenever you recover.

Done when you can

  • I can chain then calls and know each returns a new promise.

  • I always return promises started inside a then.

  • I can explain how a rejection skips ahead to the next catch.

  • I can recover in catch or re-throw when I can't handle the error.

  • I can explain the difference between then(ok, fail) and .then(ok).catch(fail).

  • I end every chain with a catch or return it to a caller.