skip to content

Error Propagation and .catch

A rejection skips forward to the nearest .catch, and whatever that handler returns puts the chain back on the happy path. Interviewers ask where you place .catch and why a plain try/catch cannot see a rejection you never awaited.

part ofJavaScriptoverview, primer and where to startread it →
on this pageshow

questions

5

In a JavaScript promise chain, what happens when the callback you passed to .then() throws an error synchronously — where does execution go next?

level: juniorimportance: must knowfreq 78%

answer

  1. handlers do not throw upward
  2. there is no stack left to catch
  3. throw and reject are one signal
  4. skips forward to the nearest handler
  5. fulfillment-only handlers pass rejections through

basics

~20 s

A throw inside a .then callback rejects the promise that .then returned, so the chain skips every later fulfillment handler until it reaches a rejection handler — a .catch or a .then second argument — which receives the thrown value.

solid answer

~40 s

Promise handlers are invoked with their result captured: if the callback returns, the derived promise fulfills with that value; if it throws, the derived promise rejects with the thrown value. So `throw new Error('boom')` inside `.then(cb)` does not propagate up the call stack — by the time `cb` runs, the original statement that created the chain has long since returned. Instead the rejection travels *forward* along the chain. Each subsequent `.then(onFulfilled)` with no rejection handler simply passes the rejection through untouched, so all those callbacks are skipped, until the first handler that can take a rejection — `.catch(fn)` or `.then(undefined, fn)` — runs with the error. That is why `throw` inside a handler and calling `reject()` are effectively the same signal in promise code.

code

javascript · 10 lines
javascript
Promise.resolve('start')
  .then(() => {
    throw new Error('boom');
  })
  .then(() => console.log('skipped'))
  .catch((err) => console.log('caught:', err.message))
  .then(() => console.log('chain continues'));

// caught: boom
// chain continues

go deeper

for a junior

Be ready to say plainly that throwing inside a .then callback rejects the promise that .then returned, and that the chain jumps forward to the nearest .catch, skipping the fulfillment handlers in between.

for a middle

Explain the mechanics: each .then creates a derived promise whose outcome is decided by how the handler finished — returned, returned-a-promise, or threw — and a .then with no rejection handler passes a rejection straight through.

for a senior

Show the API judgment: a promise-returning function that throws synchronously for bad arguments forces callers to write two error paths, so keep validation inside the async path so every failure arrives as a rejection.

for a principal

Own the contract question — decide as a codebase whether failures are signalled by rejection only, and what shape a rejection reason must have (always an Error, with enough context) so that a single failure path stays diagnosable across teams.

## The rule in one line Inside a promise chain, `throw` is a rejection. Whatever a `.then` callback throws becomes the rejection reason of the promise that `.then()` returned. ## Why it works that way Every call to `.then(onFulfilled, onRejected)` creates and returns a **new** promise — call it the *derived* promise. When the upstream promise settles, the runtime calls the matching handler and inspects how it finished: - the handler **returns a plain value** → the derived promise fulfills with that value; - the handler **returns a promise or thenable** → the derived promise adopts its eventual outcome; - the handler **throws** → the derived promise rejects with the thrown value. The third case is the whole answer. The runtime does not let the exception escape into the surrounding code, because there *is* no meaningful surrounding code: handlers run later, from the job queue, on an empty stack. There is no `try` block from the original synchronous turn still on the stack to catch anything. Converting the throw into a rejection is the only way the failure can reach code that cares about it. ```js Promise.resolve('start') .then(() => { throw new Error('boom'); }) .then(() => console.log('never runs')) .catch((err) => console.log('caught:', err.message)); // caught: boom ``` ## How a rejection travels forward A rejection moves *down* the chain, never back up it. The propagation rule is simple: if a `.then` was given no rejection handler (or a second argument that is not a function), the rejection is passed straight through to the promise it returned. That is why the middle `.then(() => console.log('never runs'))` above is skipped rather than run with `undefined`. Nothing is "cancelled" — each derived promise in between really does reject, one after another, carrying the same reason, until something is willing to handle it. `.catch(fn)` is not a separate mechanism. It is specified as `this.then(undefined, fn)` — a `.then` whose fulfillment handler is missing, which means fulfilled values pass straight through it and only rejections stop there. The *nearest* rejection handler downstream wins; handlers further along the chain never see the original rejection because the first one already consumed it. ## What counts as a throw Everything that raises inside the handler body: an explicit `throw`, a `TypeError` from reading a property of `undefined`, a `SyntaxError` from `JSON.parse` on bad input, a failed assertion. All of them land in the same place. ```js fetchProfile() .then((res) => JSON.parse(res.body)) // may throw SyntaxError .then((profile) => profile.name.trim()) // may throw TypeError .catch((err) => console.error('pipeline failed:', err)); ``` One `.catch` at the end covers both, which is exactly the ergonomic win promises were designed for: a single failure path for a multi-step pipeline. ## The important limit Only throws **inside a handler** are converted. Code that runs synchronously *around* the chain is ordinary code: ```js function load(id) { if (!id) throw new Error('id required'); // synchronous throw, not a rejection return fetchUser(id).then((u) => u.name); } ``` A caller writing `load().catch(handle)` gets a synchronous exception at the call itself and never reaches `.catch`, because `.catch` can only be attached to a promise that was actually returned. Functions that sometimes throw synchronously and sometimes reject force callers to write two error paths, so a promise-returning API should report argument errors as rejections too — for example by doing the validation inside a `.then`, or by declaring the function `async`, which converts any synchronous throw in its body into a rejection of its returned promise. ## What to say in an interview "A handler that throws rejects the promise that `.then` returned. The rejection then skips forward past every fulfillment-only handler to the nearest rejection handler. Throwing and calling `reject` are the same signal — which is why one `.catch` at the end of a chain covers every step in it."

  • If the callback throws a string instead of an Error object, does propagation still work the same way?
    Yes. The rejection reason is whatever value was thrown, with no wrapping or conversion, so `throw 'boom'` rejects with the string `'boom'`. Propagation is identical, but the handler downstream then has no `message` or `stack` to log, and `err instanceof Error` is false. That is a reason to throw `Error` instances, not a difference in how the chain routes the failure.
  • Why can't a plain try/catch wrapped around the statement that builds the chain catch a handler's throw?
    Because the handler does not run during that statement. Building the chain only registers callbacks and returns immediately, so the `try` block finishes and its stack frame is gone long before the callback executes on a later turn. When the callback finally throws, there is no enclosing `try` on the stack — the runtime turns the throw into a rejection of the derived promise instead.
  • Does the rejection also skip a .finally() sitting between the throw and the .catch?
    No — `.finally(fn)` runs its callback for both outcomes, so it executes on the way past. It does not handle the rejection, though: unless the callback itself throws or returns a rejected promise, the original rejection continues downstream unchanged to the next real rejection handler.

saying these in an interview costs you the question

  • Says the throw escapes to the surrounding function's try/catch
  • Claims skipped .then callbacks run with undefined
  • Thinks only reject() creates a rejection, not throw
  • Believes every downstream .catch sees the same error
  • Assumes a synchronous throw before returning a promise becomes a rejection

context

open as a page

When a .catch() handler on a promise chain returns a value normally instead of rethrowing, what state is the promise it returns in, and how does the rest of the chain behave?

level: middleimportance: must knowfreq 62%

basics

~20 s

A .catch handler that returns normally fulfills the promise .catch returned, with that return value. The chain is back on the success path: the next .then runs with whatever the handler returned, which is undefined if it returned nothing.

open as a page

In a promise chain, what is the difference between handling errors with the second argument of .then(onFulfilled, onRejected) and appending a separate .catch()?

level: middleimportance: should knowfreq 55%

basics

~20 s

The second argument of .then only handles rejections coming from upstream; it cannot see an error thrown by the onFulfilled callback sitting beside it. An appended .catch runs after that callback, so it covers upstream failures and the callback's own failures.

open as a page

A stored promise has .then(render) attached in one place and .then(track).catch(logError) attached in another. If that promise rejects, which handlers run, and is the rejection fully handled?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Both branches receive the rejection, but only the second handles it: logError runs, while the render branch produces its own rejected promise that nothing observes. A .catch protects the branch it belongs to, not the promise it was attached from.

open as a page

Across a codebase built on promise chains, how do you decide where .catch handlers belong and whether a given handler should recover or rethrow?

level: principalimportance: nice to knowfreq 26%

basics

~20 s

Handle failures where a decision can actually be made. Interior code rethrows so callers keep their choice; only a place that knows what a degraded result means recovers, and only the outermost boundary of an operation reports and ends the chain.

open as a page