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/finallycalls in a row, each waiting for the previous step. - Return value (in a chain)
- What a
thencallback 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
catchand return a usable value, so the chain continues normally. - Re-throw
- Throw the error again from a
catchso an outer handler deals with it. - Unhandled rejection
- A rejected promise that no
catchever 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.
Promise.resolve(2)
.then((n) => n * 10) // 20
.then((n) => n + 1) // 21
.then((n) => console.log(n)); // prints 2102Return 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.
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 step03A throw skips to the next catch
When any step throws or returns a rejected promise, the following then callbacks are skipped until a catch.
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.
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
Return from step 3
In the first example, add
return "done";at the end of step 3. Predict what step 4 logs. - 2
Re-throw from catch
In the third example, change chain A's
catchtothrow err;instead of returning the fallback. Predict which line for chain A disappears and what new red line appears. - 3
Handle the rejection
In the last example, add a
.catch(...)to thesaveDraft()chain that logsCould not save: Server said 500. Predict the full output first.
Code & diagrams
Step 3 received `21`, not a promise: returning a promise from `then` makes the chain wait for it and unwrap its value.
Preview
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 nothingThe 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
Console
Nothing logged yet.Expected console output
Without return: undefined
With return: 5The 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
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 dataThe error is reported, but the user sees nothing. Add `.catch((err) => console.log("Could not save:", err.message))` and the red line disappears.
Preview
Console
Nothing logged yet.Expected console output
Saving the draft…
The rest of the page keeps working
Uncaught (in promise) Error: Server said 500Break 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.
Break #2
Swallow errors with an empty catch
End a chain with .catch(() => {}) to make the red errors go away.
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
rejectionhandledevent fires. Listening tounhandledrejection(and callingevent.preventDefault()to silence the default console message) is how error trackers capture these failures. - ▸
Each
thencosts 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
awaitboundaries 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
catchcode checkserr instanceof Errorbefore readingerr.message, and ES2022'snew Error(msg, { cause: err })lets you wrap a low-level error in a clearer one without losing the original.
Remember this
- 1
thenreturns 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
Returning matters. If a step starts async work but doesn't
returnits promise, the chain doesn't wait for it: the next step runs at once withundefined. This is the most common promise bug. With arrow functions,.then((user) => loadOrders(user.id))returns implicitly; with braces you must writereturn loadOrders(user.id);. - 3
A rejection travels down the chain, skipping every
thensuccess callback, until it reaches acatch(or athenwith a second, error callback). So onecatchat the end handles errors from every step above it, including bugs (aTypeErrorthrown inside athen). This replaces the repeatedif (err)checks of callback hell. - 4
A
catchhandles the error: the promise it returns fulfils with whatever thecatchreturns, 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 outercatch. Acatchin the middle of a chain only protects the steps above it. - 5
p.then(onOk, onFail)is not the same asp.then(onOk).catch(onFail). In the first form,onFailhandles errors fromponly, not errors thrown byonOk. In the second,catchalso catches mistakes insideonOk. Prefer.then(...).catch(...)unless you specifically need the first. - 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 anunhandledrejectionevent onwindow, 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 acatch, or be returned to a caller who handles it.
Explain it without notes
What does then return, and how is its value decided?
How do errors travel through a promise chain, and how do you recover from one?
What's the difference between p.then(ok, fail) and p.then(ok).catch(fail)?
What is an unhandled rejection, how is it reported, and how do you avoid it?
Practice
Rewrite the callback-hell example from Topic 10.4 as a flat chain, using later(value) that returns a promise fulfilled after 50 ms.
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
catchat the end is simple and catches everything, but can't tell which step failed or recover per step; acatchafter 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
thencalls and know each returns a new promise.I always
returnpromises started inside athen.I can explain how a rejection skips ahead to the next
catch.I can recover in
catchor 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
catchor return it to a caller.