Errors travel down the chain
A promise rejects when the executor calls reject(reason) or when a callback throws.
From that point the rejection falls through the chain, and four rules describe the whole behavior.
- Every normal
.thenin between is skipped. - The first
.catch, or a.thenwith a second argument, receives the reason. - If the
.catchreturns normally, the chain is recovered and continues fulfilled from there. .finally(fn)runsfneither way, for cleanup, and passes the result through untouched.
One .catch at the end of a chain covers every step above it, which is the promise version of wrapping everything in a single try/catch block.
The recovery rule is the one people underuse. A .catch in the middle of a chain can supply a default and let the rest of the chain proceed as though nothing failed.
This lesson matters beyond interviews. A failing network call is normal Tuesday traffic rather than an exception, and an unhandled rejection can take down an entire Node process.
Error paths are where junior code and production code differ the most, and reviewers notice them first.
A throw, a catch, and a recovery
The throw in step 1 skips step 2 entirely.
Promise.resolve("ok") .then(() => { throw new Error("boom"); }) .then(() => { console.log("step 2 (skipped)"); }) .catch((err) => { console.log("caught: " + err.message); return "recovered"; }) .then((v) => { console.log("after catch: " + v); }) .finally(() => { console.log("cleanup"); });
Output
caught: boom
after catch: recovered
cleanupThe rejection lands in the catch, and the chain continues afterward because the catch returned a value.
step 2 (skipped) never prints, which is the falling-through rule in action. That callback was attached and simply never invoked.
Returning "recovered" is what makes the following .then run. A catch that returned nothing would still recover the chain, and the next link would receive undefined.
.finally receives no argument at all, and that is by design. It cannot see the value or the reason, so it cannot accidentally change the outcome.
Returning a value from .finally is ignored too. Throwing inside it is not, and that rejects the chain, which is worth remembering for cleanup code that might itself fail.
catch can rethrow to translate errors
A .catch does not have to recover.
If it throws, the chain becomes rejected again from that point, and the new reason replaces the old one.
This is the standard way to translate errors. Log the low-level reason, then hand a user-friendly error to the next layer.
Everything between the rethrow and the next .catch is skipped, by the same falling-through rule as before.
The reason to translate rather than pass through is boundaries. A UI layer should not have to know what a database timeout looks like, and a database error message should not reach a user.
Keeping the original error is good practice when you rethrow. new Error("could not load user", { cause: err }) preserves the underlying reason for logs while presenting a clean message.
Rethrowing the same error is also legitimate. A .catch that logs and then rethrows err acts as a passive observer, useful for metrics without changing behavior.
Two catches, one translation
The first catch logs and rethrows, and the second one reports.
Promise.reject(new Error("db down")) .catch((err) => { console.log("log: " + err.message); throw new Error("could not load user"); }) .catch((err) => console.log("ui: " + err.message));
Output
log: db down ui: could not load user
The second catch is the one a UI layer would own, and it never sees the database detail.
Promise.reject(...) starts an already-rejected chain, which is the mirror of Promise.resolve and a convenient way to test error paths.
The two catches do not both handle the same error. The first handles "db down" and produces a new failure, and the second handles that new one.
A single .catch cannot catch its own throw, which is why the second one is required. Rejections only travel downward.
Removing the second catch turns this into an unhandled rejection, since the translated error has nowhere to land. Translating without a downstream handler is worse than not catching at all, because the original stack is gone.
The rejection skips steps 2 through 5 and goes straight to the catch.
Rejections bypass every normal .then on their way down and stop at the first handler that can take them.
That is why a single trailing .catch is enough to guard a whole chain, and it is the property that makes long chains practical.
The catch cannot resume in the middle, which is the tradeoff. Recovering from step 1 does not let steps 2 through 4 run afterward, so a chain with one handler is all-or-nothing.
Putting a .catch right after the step that can fail is the fix when partial recovery matters. It handles the failure locally and lets the remaining steps proceed.
With no catch anywhere you would get an unhandled rejection instead, which the next block covers.
The unhandled rejection
If a rejection reaches the end of a chain with no .catch, the runtime reports an unhandled promise rejection.
In modern Node this crashes the process by default, which is a deliberate choice. An error nobody handled is a bug, and failing loudly is safer than continuing in an unknown state.
Browsers are gentler and log to the console, so the same bug is easy to miss during development and expensive in production.
The detection is not instant. The runtime waits until the microtask queue drains, so attaching a .catch later in the same tick still counts as handled.
The interview take-away is one sentence. Every promise chain you start should end in a .catch, or be awaited inside a try/catch block, which lesson 6-1 introduces.
Two patterns deserve extra care. A promise you create and never attach anything to, and a .then inside a forEach where the results are dropped, both produce rejections with nowhere to go.
Falling back to a default
fetchUser rejects for unknown ids.
function fetchUser(id) { return new Promise((resolve, reject) => { if (id === 1) resolve("Ada"); else reject(new Error("unknown user")); }); } fetchUser(42) .then((name) => console.log("hello " + name)) .catch(() => console.log("fallback: guest"));
Output
fallback: guest
Chaining .catch after the .then turns a crash into a printed fallback.
The .then callback never runs, since the promise was already rejected before it was attached.
This catch ignores its argument entirely, which is a reasonable choice for a fallback and a bad habit for anything else. Swallowing the reason means losing every clue about why it failed.
A better version logs the reason and still falls back, as in .catch((err) => { console.log(err.message); console.log("fallback: guest"); }).
Note the executor calls resolve or reject synchronously here. That is legal and useful for tests, and it still delivers the result through the microtask queue, so .then never runs before the current script finishes.