skip to content

Promise Fundamentals

The core promise contract: how a promise settles, how .then builds a new promise from what you return, and how a rejection travels down a chain. Interviewers start here because everything else in async JavaScript is sugar over these rules.

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

explore

questions

13

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

What are the three states of a JavaScript promise, and what happens if the executor passed to new Promise() calls resolve() and then calls reject() a moment later?

level: juniorimportance: must knowfreq 78%

basics

~20 s

A promise is pending, fulfilled, or rejected. The first resolve() or reject() settles it permanently; every later call is silently ignored. So a promise that fulfilled can never become rejected, and its value can never change.

open as a page

In JavaScript, what does calling .then() on a promise return, and how does the value your callback returns affect the next link in the chain?

level: juniorimportance: must knowfreq 78%

basics

~20 s

Every .then() call returns a brand-new promise. Returning a plain value from the callback fulfils that new promise with the value; returning a promise or thenable makes the new promise adopt that one's eventual outcome instead of nesting it.

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

Given `getUser().then(user => { saveUser(user); }).then(() => console.log('done'))`, where `saveUser` returns a promise, why does "done" print before the save finishes, and what is the fix?

level: middleimportance: must knowfreq 66%

basics

~20 s

The callback never returns the promise from saveUser, so it returns undefined and the chain does not wait for the save. Fix it by returning the promise: user => saveUser(user), or add an explicit return inside the braces.

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

The executor function you pass to new Promise() runs immediately and synchronously. What practical consequences does that have, and how would you arrange for the work to start only when a caller actually asks for it?

level: middleimportance: should knowfreq 55%

basics

~20 s

Constructing a promise runs the executor right away, so the work it kicks off is already in flight before anyone subscribes. Promises are eager, not lazy. To defer work, hand out a function that creates the promise instead of the promise itself.

open as a page

How does Promise.resolve(value) differ from new Promise(resolve => resolve(value)), and what does Promise.resolve return when the argument is already a native promise?

level: middleimportance: should knowfreq 44%

basics

~20 s

Promise.resolve is a shortcut for a promise that is already settled, with one extra rule: given a native promise it returns that exact object instead of wrapping it. The constructor form always allocates a new promise. Promise.reject never unwraps anything.

open as a page

In a JavaScript promise chain, what does .finally() pass on to the next link, and in which cases can its callback change the outcome?

level: middleimportance: should knowfreq 50%

basics

~20 s

Promise.prototype.finally runs its callback with no arguments and normally passes the original fulfilment value or rejection reason straight through, discarding whatever the callback returns. It only changes the outcome if the callback throws or returns a rejected promise.

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

A module caches the promise returned by its initialisation function in a module-level variable so that concurrent callers share one in-flight request. The very first attempt rejects, and from then on every caller in the process gets the identical failure with no further network activity. Explain what the promise lifecycle is doing here, and how you would fix it.

level: seniorimportance: should knowfreq 38%

basics

~20 s

The cache holds a settled promise, and settlement is permanent. A rejected promise replays the same reason to every later subscriber and never re-runs the work, so the cached failure is now the module's permanent answer until the process restarts.

open as a page

A request handler hangs on one code path: a .then() callback returns an object from a third-party client that happens to expose a then method, and nothing after it ever runs. What did the promise machinery do with that object, and how would you confirm and contain it?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Any object with a callable then method is a thenable, so the chain assimilated it: it called then(resolve, reject) and now waits for one of those to be invoked. A thenable that calls neither leaves the chain pending forever.

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