Promise chaining has more edge cases than most devs expect — .catch() can recover a chain, .finally() silently passes values through, and two concurrent chains interleave breadth-first, not depth-first. Ten questions to sharpen your mental model before the interview.
Q1 — Returning a plain value from .then()
Loading editor...
Show answer
Output:
1
11
Why: When a .then() callback returns a plain value, that value is automatically wrapped in a resolved Promise for the next handler. v + 10 is 11, so the second .then() receives 11.
Q2 — Returning a rejected Promise from .then()
Loading editor...
Show answer
Output:
start
catch: oops
Why: When .then() returns a rejected Promise, the chain switches to the rejected track. The next .then() is skipped entirely, and the nearest .catch() fires. Returning Promise.reject(x) from inside .then() is equivalent to throwing x.
Q3 — .catch() recovers the chain
Loading editor...
Show answer
Output:
caught: error
then: recovered
Why: .catch() that returns a plain value converts the chain back to the resolved track. The .then() after .catch() runs normally and receives the returned value. Think of .catch() as a checkpoint: handle the error, return something, and the chain continues as if nothing went wrong.
Q4 — .catch() re-throwing keeps the chain rejected
Loading editor...
Show answer
Output:
first catch: first error
second catch: second error
Why: If .catch() throws (or returns Promise.reject()), the chain stays on the rejected track. The error propagates to the next .catch(). This is how error re-throwing works — you can inspect the error and decide to re-throw it or swallow it.
Q5 — .finally() passes the value through
Loading editor...
Show answer
Output:
finally
then: 42
Why: .finally() receives no arguments — it does not see the resolved value. Its return value is also ignored (unless it throws). The original value 42 passes through unchanged to the next .then(). This makes .finally() ideal for cleanup like hiding a spinner — you don't need the value, and you don't want to interfere with it.
Q6 — .finally() throwing overrides the resolved value
Loading editor...
Show answer
Output:
catch: finally error
Why: If .finally() throws (or returns a rejected Promise), the original resolved value is discarded and the chain switches to rejected. The .then() is skipped and .catch() fires with the error from .finally(). This is the one case where .finally() changes the outcome of the chain.
Q7 — .then(onFulfilled, onRejected) does not catch its own onFulfilled's error
Loading editor...
Show answer
Output:
catch: thrown in onFulfilled
Why: The onRejected in .then(onFulfilled, onRejected) handles rejections from the preceding promise — not errors thrown by onFulfilled in the same call. When onFulfilled throws, the error escapes past onRejected and is caught by the next .catch() in the chain. This is why .then(fn).catch(err) is usually preferred over .then(fn, err) — the standalone .catch() also covers errors from fn.
Q8 — Two concurrent chains interleave breadth-first
Loading editor...
Show answer
Output:
1
3
2
4
Why: Both chains start with already-resolved Promises, so both first .then() callbacks are queued as microtasks immediately. The microtask queue at the end of sync: [cb1, cb3].
cb1runs → logs1→ schedulescb2. Queue:[cb3, cb2]cb3runs → logs3→ schedulescb4. Queue:[cb2, cb4]cb2runs → logs2. Queue:[cb4]cb4runs → logs4.
The chains interleave breadth-first, not depth-first. Many developers expect 1 2 3 4 — this is one of the most commonly wrong answers in senior JS interviews.
Q9 — resolve(promise) adds an extra microtask tick
Loading editor...
Show answer
Output:
inner: inner
outer: inner
Why: When resolve is called with another Promise (inner), the engine cannot resolve outer directly — it must first call inner.then(resolveOuter, rejectOuter) to "adopt" the inner Promise's state. That adoption is itself a microtask.
Timeline:
- Sync ends. Microtask queue:
[adopt inner→outer, inner.then(log "inner")] - Adoption runs →
outerresolves → queuesouter.then(log "outer") - Queue:
[inner.then(log "inner"), outer.then(log "outer")] inner: innerlogs, thenouter: innerlogs.
If you had used resolve("inner") (a plain value) instead, outer would resolve in the same microtask tick as inner, and the order would be determined by registration order. The extra adoption step is the critical difference.
Q10 — Promise.resolve(existingPromise) returns the same object
Loading editor...
Show answer
Output:
true
false
Why: Promise.resolve(x) has a fast path: if x is already a native Promise from the same realm, it returns x unchanged — no new object is created, no wrapping. p === p2 is true.
new Promise(resolve => resolve(p)) always creates a new Promise object. Even though it eventually resolves to the same value as p, it is a different object. p === p3 is false. This also means p3 has the extra adoption tick from Q9 — p would resolve first.
Key Rules
| Pattern | What happens |
|---|---|
.then(v => plainValue) | Next handler receives plainValue (wrapped in resolved Promise) |
.then(v => Promise.reject(e)) | Chain switches to rejected track; next .catch() fires |
.catch(e => value) | Recovery: chain becomes resolved; next .then() runs |
.catch(e => { throw e2 }) | Re-throw: chain stays rejected; next .catch() fires |
.finally(fn) | fn receives no args; original value passes through unless fn throws |
.finally(() => { throw e }) | Overrides the resolved value; chain becomes rejected with e |
.then(onFulfilled, onRejected) | onRejected does not catch errors from onFulfilled in the same call |
| Two concurrent chains | Interleave breadth-first: 1, 3, 2, 4 — not 1, 2, 3, 4 |
resolve(anotherPromise) | Adopts state via extra microtask tick — outer resolves one beat after inner |
Promise.resolve(existingPromise) | Returns the same object — no wrapper, no extra tick |
Go Deeper
- Output Quiz #6 — Promises & async/await Ordering — the previous quiz: executor timing, .then() as microtask, basic error handling
- Output Quiz #8 — Promise Combinators — the next quiz: Promise.all, race, allSettled, any
- Output Quiz #3 — The Event Loop & Task Ordering — the microtask queue model that powers all of this
