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?
answer
- a promise is a value, not a pipe
- two .then calls, two chains
- the catch belongs to its branch
- count the leaves of the tree
- helpers that tap and return the original
basics
~20 sBoth 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.
solid answer
~50 sCalling `.then()` twice on the same promise forks it into two independent chains. Both derived promises reject when the source rejects, and each needs its own handler. Here `render` and `track` are both skipped, the `.catch(logError)` handles the second branch, and the first branch's rejection sits unobserved — a real failure your logging never sees. This is the practical meaning of "the nearest `.catch` wins": nearest *along a branch*, not nearest in the file. The fixes are to make the branching explicit — keep one linear chain, or give every fork its own terminal handler — and to be careful with helpers that take a promise, attach a side-effect `.then`, and return the original: they silently create an unterminated branch. In review, count the leaves of the promise tree; every leaf needs a rejection handler.
code
javascript · 13 linesconst p = Promise.reject(new Error('fetch failed'));
p.then((u) => console.log('render', u)); // branch A: no rejection handler
p.then((u) => console.log('track', u)).catch((e) => console.log('logged:', e.message));
// logged: fetch failed <- branch B only; branch A's rejection is unobserved
// Linear alternative: one chain, one terminal handler
Promise.reject(new Error('fetch failed'))
.then((u) => {
console.log('track', u);
return console.log('render', u);
})
.catch((e) => console.log('logged once:', e.message));go deeper
Know that each .then() call on a promise starts a separate chain, so a .catch written on one of them does not protect the others.
Explain the fork mechanically: reactions are registered on the source promise and each returns its own derived promise, so a rejection is delivered to every branch and must be handled per branch.
Demonstrate the diagnosis — look for promises referenced more than once, helpers that attach a reaction and return the original, and cached promises — and restructure into one chain or terminate every leaf.
Own the convention: decide whether forking a shared promise is allowed at all, who owns user-visible reporting when it is, and what a cached promise does on rejection so a transient failure cannot become permanent.
## A promise is a value, not a pipeline A promise can have any number of reactions attached, and each `.then`/`.catch` call returns a *different* derived promise. The result is a tree, not a line. When the root rejects, the rejection is delivered to every registered reaction, and each branch then propagates independently down its own descendants. ```js const p = fetchUser(id); p.then(render); // branch A p.then(track).catch(logError); // branch B ``` If `p` rejects: `render` and `track` are both skipped (they are fulfillment handlers), branch B's `.catch` runs `logError`, and branch A's derived promise is left rejected with no handler. The chain in branch A ends there, unobserved. ## Why this is easy to get wrong Reading top to bottom, it looks like one `.catch` "covers the fetch". It does not — `.catch` is not registered on `p` as a global error handler for everything `p` feeds. It is registered on the promise that `p.then(track)` produced. Nothing you attach to one branch can influence another. The same shape appears in subtler forms: ```js function withTiming(promise) { const start = Date.now(); promise.then(() => metrics.record(Date.now() - start)); // orphan branch! return promise; // caller chains here } ``` The caller's `.catch` handles the branch the caller built. The internal `.then` created a second branch that nothing terminates, so a rejection produces an unobserved failure inside a helper that looks read-only. The fix is to keep the tap in the returned chain — `return promise.finally(() => metrics.record(...))` — or to terminate the tap branch itself with a handler. ## Diagnosing it The symptom is a failure that your error reporting under-counts or misses entirely: one log line where you expected two, or a code path whose failures never appear at all even though the surrounding request clearly failed. When you suspect it, ask three questions: 1. **Is this promise referenced more than once?** A promise stored in a variable, a field, a cache, or a module-level singleton is a fork candidate; a promise consumed inline by exactly one chain cannot fork. 2. **How many leaves does the tree have?** Every `.then`/`.catch`/`.finally` call on a promise starts a branch. Count the ones whose result is discarded — those are the leaves that must carry a handler. 3. **Does any helper attach a reaction and return the original?** That is the fork you will not see at the call site. ## Shapes that avoid it **One linear chain.** The simplest cure: do the work in sequence and terminate once. ```js fetchUser(id) .then((user) => { track(user); return render(user); }) .catch(logError); ``` **Deliberate forks, each terminated.** When genuine parallel consumers are needed, give each its own handler and say so: ```js const p = fetchUser(id); p.then(render).catch(logError); p.then(track).catch(logError); ``` **Join the forks.** If both branches must complete before you continue, combine them so a single handler covers both outcomes rather than leaving two loose ends. ## Caching amplifies it A memoised promise makes the tree wide by design: ```js let configPromise; const getConfig = () => (configPromise ??= loadConfig()); ``` Every caller attaches its own branch, so every caller must handle rejection — and because the promise is settled once and cached, a rejection is replayed to every later subscriber rather than retried. That is usually the wrong caching policy for a failure: many teams clear the cached promise in a rejection handler so the next call retries, which also gives the cache one guaranteed terminal handler of its own. ## What to say in an interview "`.then` on the same promise forks it, and each fork is a separate chain with its own error path. The rejection is delivered to both, so a `.catch` on one branch does nothing for the other — that branch's rejection is simply unobserved. I look for promises referenced more than once, and for helpers that attach a reaction and return the original, and I make sure every leaf of the tree ends in a handler."
- A helper attaches a .then for metrics and returns the original promise. How do you fix the orphan branch it creates?Return the derived promise instead of the original, so the caller's handler covers the tap: `return promise.finally(() => record())`, or `return promise.then(v => { record(); return v; })`. If the helper genuinely must return the original object identity, terminate the tap branch itself with its own `.catch`, so the observation branch cannot leave an unobserved rejection behind.
- Does a rejection get delivered twice if two branches both have a .catch?Yes — each branch has its own handler and both run, with the same reason object. That is correct behaviour, not a bug, but it means naive error reporting double-counts one failure. When you fork deliberately, decide which branch owns the user-visible reporting and let the others handle quietly, or join the branches so one handler reports once.
- How does caching a promise interact with this?A cached promise is a deliberate fork point: every consumer attaches a branch and each must handle rejection itself. Because the promise is settled once, a rejection is replayed to every later subscriber instead of retrying, so a transient failure becomes permanent for the process lifetime. Clearing the cached promise from a rejection handler restores retry and gives the cache one terminal handler.
saying these in an interview costs you the question
- Thinks one .catch covers every consumer of the promise
- Says only the branch with the handler receives the rejection
- Assumes attaching a .then does not create a new chain
- Believes a rejection is delivered to just one handler
- Treats a cached rejected promise as if it will retry