In a JavaScript promise chain, what happens when the callback you passed to .then() throws an error synchronously — where does execution go next?
answer
- handlers do not throw upward
- there is no stack left to catch
- throw and reject are one signal
- skips forward to the nearest handler
- fulfillment-only handlers pass rejections through
basics
~20 sA 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 sPromise 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 linesPromise.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 continuesgo deeper
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.
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.
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.
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