skip to content

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%

answer

  1. catch where a decision can be made
  2. interior code rethrows
  3. recover, translate, or report
  4. fallbacks must be shape-compatible
  5. every branch is a leaf that terminates

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.

solid answer

~50 s

My rule is that a `.catch` is a *decision point*, so it belongs wherever the code has enough context to decide something. That is usually two places: an interior step with a real fallback the rest of the chain can consume — an empty list, a cached value — and the outermost boundary of an operation, which reports and terminates. Everything in between rethrows, because a library that swallows a failure removes its caller's ability to retry, degrade, or fail loudly. The corollary is a rule about return values: a recovering handler must produce something shape-compatible with the success path, otherwise it has not recovered, it has moved the crash downstream. And because forking a promise creates independent branches, "one `.catch` at the end" is a statement about every leaf of the chain, not about the last line of the file.

go deeper

for a junior

Focus on the simple habit: put a .catch at the end of the chain that starts the work, and do not add .catch inside helper functions just to be safe.

for a middle

Explain why interior code should rethrow — a helper cannot know whether the caller wants a retry, a default, or a hard failure — and why a fallback value must match the shape the success path produces.

for a senior

Show the boundary reasoning in a real system: recovery near the top where product context lives, translation at module edges, one reporting point per operation, and observation done with pass-through handlers.

for a principal

Own the tradeoff explicitly — availability versus truth for each failure class, uniform central handling versus precise local recovery — and turn it into a small set of sanctioned recovery helpers plus review rules that ban silent handlers.

## The organising question Every `.catch` answers "what should happen now?" — so it should only exist where that question has an answer. Code that catches without being able to answer it either swallows the failure or re-emits it after adding noise. The design problem is deciding which layers have the answer. ## Three roles, and only three **Recover.** A handler that turns a failure into a usable value: `loadRecommendations().catch(() => [])`. The test is mechanical — could the next step tell the difference and still be correct? If the fallback has the same shape as the success value, yes. If the handler returns `undefined` and the next step does `result.length`, no: that is not recovery, it is a deferred crash with a worse stack trace. **Translate.** A handler at a module boundary that replaces a low-level reason with a domain-level one and rethrows. This is how a storage error becomes `UserNotFound` at the repository edge. Translation must be *lossless enough* to debug: keep whatever you need from the original before you throw the new error, because the reason is replaced outright. **Report and end.** The outermost handler of an operation — a request handler, an event handler, a job runner. This is the only place a chain legitimately ends without rethrowing, because there is no caller left to inform. A fourth thing people write — log and continue — is not a role. It is the recover role executed by accident, returning `undefined`. ## Where the boundaries are The useful boundary is *ownership of the outcome*. Library and utility code almost never owns it: `fetchUser` cannot know whether a caller wants a retry, a placeholder, or a 500, so it must rethrow. Application code near the entry point does own it, and that is where recovery policy lives. A codebase that inverts this — catching deep, reporting shallow — produces the characteristic symptom of silently degraded output: a page that renders empty rather than erroring, and no alert. A second boundary is *user-visible reporting*. Exactly one place per operation should decide what the user or the caller sees; the others may observe and rethrow. Otherwise one failure is reported three times at three severities and the on-call signal degrades. ## Rules worth enforcing - **Interior code rethrows.** A `.catch` in a shared helper must end in `throw` unless it is producing a documented fallback. - **Every leaf terminates.** Because `.then` forks, "one `.catch` at the end" must be read as "every branch ends in a handler". Reviews should count branches, not lines. - **Recovery values are shape-compatible.** Prefer returning the same type the success path returns; if the caller must distinguish, return an explicit discriminated result rather than a bare `null` that fails one step later. - **Observation uses a pass-through.** For metrics and tracing, use `.finally` or a handler that rethrows, never a bare `.catch` that ends the chain. - **Silence is a defect, not a style.** Any handler that neither rethrows nor produces a fallback must at minimum record the failure somewhere an operator sees. ## The tradeoffs to name out loud *Recovery improves availability and costs truth.* A degraded page beats an error page for a recommendations widget and is disastrous for a balance display. That is a product decision, and the value of putting recovery near the top of the stack is that a product decision is being made by code that knows the product. *Translation improves comprehensibility and costs detail.* Domain errors read beautifully in logs until the day you need the driver's original message. Decide what a translated error must retain, and be consistent about it. *Central handling is consistent but coarse.* One boundary handler for a whole service gives uniform reporting and no per-case nuance; per-call handling is precise and drifts. Most codebases want a small number of standard recovery helpers plus one boundary handler, rather than ad-hoc `.catch` calls scattered through the middle. ## What good looks like in review For each `.catch` in a diff, ask: what does this return, what comes after it, and could the caller have made a better decision than this handler just made? If the answer to the last one is yes, the handler is in the wrong place. Most codebases end up with far fewer rejection handlers than they started with, positioned at recognisable boundaries — and the failures that used to vanish start showing up in logs at their real source. ## What to say in an interview "A `.catch` is a decision point, so it belongs where the code can decide: an interior step with a genuine, shape-compatible fallback, and the outermost boundary that reports and ends. Everything in between rethrows, otherwise the library has taken the caller's decision away. And since `.then` forks a promise, 'catch at the end' means every branch ends in a handler."

  • How do you keep a translated error debuggable when the handler replaces the original reason?
    Decide up front what translation must retain and enforce it in one helper rather than per call site. In practice that means capturing the original reason on the new error before throwing, and logging the original at the translation point when the domain error will not carry it. The failure mode to avoid is a domain error that reads well and no longer tells anyone which dependency actually failed.
  • A team proposes one global rejection handler at the service boundary and no .catch anywhere else. What do you push back on?
    Uniform reporting is genuinely good, but a single handler cannot express legitimate per-case recovery — the widget that should degrade to empty, the cache read that should fall back to origin. I would keep the boundary handler as the backstop and allow a small set of named recovery helpers with documented fallbacks, so recovery is a deliberate, reviewable choice rather than an ad-hoc catch in the middle of a chain.
  • How do you decide whether a failure should degrade the response or fail it?
    By what a wrong-but-present answer costs. Degrade when the field is supplementary and its absence is visibly honest — recommendations, related items, a view count. Fail when the value is the reason the caller asked — a balance, a permission check, an order total — because a fallback there is indistinguishable from a real answer. The rule of thumb: never fall back to a value that could be mistaken for truth.

saying these in an interview costs you the question

  • Catching in every function to be defensive
  • Treats logging in a library as handling the failure
  • Recovers with null and lets the next step crash
  • Reports the same failure at several layers
  • Assumes a single trailing catch covers forked branches

context