skip to content

Inside a try block in a JavaScript async function you write `const user = await getUser(id).catch(() => null);`. If `getUser` rejects, what is `user`, and does the surrounding catch block run?

level: middleimportance: should knowfreq 44%

answer

  1. catch returns a new promise
  2. handler's return becomes the fulfilment
  3. await sees a fulfilled promise
  4. outer catch stays silent
  5. rethrow inside to reach it

basics

~20 s

user is null and the surrounding catch never runs. The .catch handler returns a new promise that fulfils with the handler's return value, so by the time await sees it there is no rejection left to throw.

solid answer

~50 s

`.catch(fn)` does not just observe a rejection — it returns a **new** promise, and if `fn` returns normally that promise **fulfils** with `fn`'s return value. So the rejection is converted to a fulfilment of `null` before `await` ever looks at it; `await` yields `null`, nothing is thrown, and the enclosing `try`'s catch clause is dead code for this call. The idiom is fine when a fallback really is correct — an optional lookup, a cache miss — and dangerous when it is reflexive, because a handler like `.catch(() => {})` returns `undefined` and that value flows downstream to fail somewhere unrelated. If you want the outer handler to see the failure, the inner handler must rethrow: `.catch(err => { metrics.inc('miss'); throw err; })` produces a rejected derived promise, and then `await` throws and the outer catch does run. In review the question I ask is which of the two handlers owns this failure — having both is usually a sign nobody decided.

code

javascript · 18 lines
javascript
async function getUser() { throw new Error('service down'); }

async function main() {
  try {
    const user = await getUser().catch(() => null);
    console.log('user is', user);        // user is null
  } catch {
    console.log('outer catch');          // never reached
  }

  try {
    await getUser().catch(err => { throw err; });   // rethrow
  } catch (err) {
    console.log('outer catch:', err.message);       // outer catch: service down
  }
}

main();

go deeper

for a junior

Know that .catch handles the rejection and produces a value, so an await on it succeeds. Recognise that the enclosing try/catch will not fire once an inner handler has dealt with the failure.

for a middle

Explain that .catch returns a derived promise which fulfils with the handler's return value, and that only rethrowing from the handler leaves a rejection for the outer catch. Name the silent-undefined variants.

for a senior

Show judgment about which handler owns a failure: per-call fallbacks where absence is a real domain value, block-level handling where any failure aborts the operation, and why a fallback that masks an outage is worse than an error.

for a principal

Set the convention for fallback semantics across the codebase — where degraded results are permitted, how they are recorded so they do not look like healthy responses, and why blanket per-call catches erode the ability to detect dependency failures.

## What `.catch` returns The key fact is that `.catch(fn)` is not a side-effecting observer; it is a transformation. It returns a new promise whose settlement depends on `fn`: - `fn` returns a value `v` → the derived promise **fulfils** with `v`. - `fn` returns a promise → the derived promise adopts that promise's outcome. - `fn` throws (including rethrowing the original) → the derived promise **rejects** with what was thrown. Only the third case leaves anything for a surrounding `try`/`catch` to handle, because only that case gives `await` a rejection to rethrow. ```js try { const user = await getUser(id).catch(() => null); console.log('user is', user); // user is null } catch { console.log('outer catch'); // never reached } ``` ## Why this is worth understanding rather than memorising It is the same rule that governs `try`/`catch` in synchronous code: once a handler completes normally, the error is handled and control continues after it. `.catch` is that handler, expressed on the chain instead of around a block. `await` then simply reports the outcome of the chain it is given — and the chain it is given already ends in a fulfilment. A useful reframing: `await p.catch(f)` is closest to an inline `try { await p } catch (e) { f(e) }` collapsed into an expression, with the crucial difference that the *result value* of the whole expression comes from `f` on the failure path. ## The legitimate uses The pattern is a compact per-operation fallback, and it reads well when the fallback is a real domain decision: ```js const avatar = await fetchAvatar(id).catch(() => DEFAULT_AVATAR); const cached = await cache.get(key).catch(() => undefined); // cache is optional ``` It also lets you give different fallbacks to different calls inside one `try` block, which a single surrounding `catch` cannot express — the block-level handler cannot tell which of five awaits failed without extra state. ## The failure modes **Silent undefined.** `.catch(() => {})` and `.catch(console.error)` both fulfil with `undefined`, because an arrow body in braces returns nothing and `console.error` returns `undefined`. The failure vanishes and a downstream `TypeError: Cannot read properties of undefined` appears somewhere with no connection to the real cause. **Fallback that is not a real fallback.** Returning `null` from a network failure is only correct if the caller genuinely treats "absent" and "could not determine" the same way. For a permissions check or a balance lookup they are emphatically not the same, and the fallback turns an outage into wrong behaviour rather than an error. **Dead outer handler.** A `try`/`catch` around code where every await already ends in `.catch(...)` never fires. It reads as a safety net and is not one — the reviewer's illusion is the real cost. **Handling too much.** `.catch(() => null)` catches every rejection reason, including programmer errors like a `TypeError` from a typo inside `getUser`. A discriminating handler is better: rethrow anything you did not specifically expect. ```js const user = await getUser(id).catch(err => { if (err.code === 'NOT_FOUND') return null; throw err; // outer catch handles the rest }); ``` ## Ordering: `.catch` before versus after `await` `await getUser(id).catch(f)` attaches the handler to `getUser(id)` first, then awaits the derived promise — property access and the call bind tighter than `await`. There is no way to write it so `await` runs first, and `(await getUser(id)).catch(f)` means something entirely different: it awaits the user object and then calls `.catch` **on that object**, which throws a `TypeError` unless the resolved value happens to be a thenable. That parenthesisation mistake is a real one under time pressure. ## Choosing one owner per failure The design question is which layer decides what a failure means. Per-call `.catch` is right when the decision is specific to that call and produces a value. Block-level `try`/`catch` is right when any failure in the region means the same thing — abort the operation, report it. Using both for the same failure is not defence in depth; it means the inner one wins and the outer one is decoration.

  • How would you make the surrounding catch block run while still recording something in the inner handler?
    Rethrow from the inner handler: `.catch(err => { metrics.inc('getUser.fail'); throw err; })`. Throwing makes the derived promise reject, so `await` rethrows at that line and the enclosing catch clause runs. Returning any value — including implicitly returning undefined from a braced arrow body — fulfils instead and keeps the outer handler dead.
  • What does `.catch(console.error)` leave the awaited expression evaluating to?
    `undefined`, because `console.error` returns undefined and that becomes the derived promise's fulfilment value. The failure is logged but the calling code proceeds with undefined, typically producing an unrelated TypeError further downstream. If the caller cannot use a fallback value, the handler must rethrow.
  • Is `(await getUser(id)).catch(fn)` equivalent to `await getUser(id).catch(fn)`?
    No. The parenthesised form awaits first and then calls `.catch` on the resolved *value*, which throws a TypeError unless that value is itself a promise — and on rejection it never reaches the call at all. The unparenthesised form attaches the handler to the promise before awaiting, which is the intended reading.

saying these in an interview costs you the question

  • Thinks .catch only observes and leaves the rejection in place
  • Expects the surrounding catch to run as a second safety net
  • Uses .catch(console.error) and continues with the value
  • Treats a null fallback as equivalent to an error for any lookup
  • Believes await runs before the .catch attached in the same expression

context