Inside a JavaScript async function you write `try { doWork(); } catch (err) { ... }`, where `doWork` is an async function that rejects. Why does the catch block never run, and where does that error end up?
answer
- the call returned, nothing threw
- try/catch is synchronous and lexical
- try frame is gone by rejection time
- no await means no throw point
- the enclosing function still reports success
basics
~20 sCalling an async function returns a promise immediately without throwing, so the try block completes normally and its frame is gone before the promise rejects. try/catch is a synchronous, stack-based mechanism, so the rejection escapes it entirely and is left unhandled.
solid answer
~50 s`doWork()` is a normal function call that returns a promise and returns *now*. Nothing throws during the try block, so the block finishes and the handler frame is popped; the rejection happens on a later microtask, when no `try` is live to receive it. `try`/`catch` only catches values thrown on the current synchronous execution path — `await` is what puts a rejection back onto that path, and there is no `await` here. The rejected promise has no handler attached, so it surfaces as an unhandled rejection rather than as a caught error. The fix is to give the promise to the try block: `await doWork()`, or `return doWork()` if this function is meant to forward the failure to its own caller. This is the single most common async bug I look for in review, because the code reads as if it is protected and the failure shows up somewhere completely different.
code
javascript · 15 linesasync function boom() { throw new Error('kaboom'); }
async function run() {
try {
boom(); // no await: promise created and dropped
console.log('try finished');
} catch (err) {
console.log('never printed', err);
}
}
run().then(() => console.log('run() reported success'));
// try finished
// run() reported success
// then: unhandled rejection for 'kaboom'go deeper
Know that calling an async function without await gives you a promise, not a result, and that nothing is thrown at the call site. Recognise the missing await as the bug.
Explain the ordering: the call returns normally, the try block exits, and only then does the promise reject with no live handler. Name await, return, and .catch as the three ways to reconnect it.
Show how this surfaces in production — an operation logging success while its work failed, plus a context-free unhandled-rejection line — and where it hides: async callbacks handed to forEach or to non-promise-aware APIs.
Treat it as a systemic defect class rather than a code-review catch: enforce it with lint rules, decide the codebase's policy on deliberate fire-and-forget, and define how orphaned rejections are reported so they are never a silent success.
## The mechanics in one sentence `try`/`catch` is a synchronous, lexical, stack-based construct; a promise rejection is an asynchronous, value-based signal. `await` is the bridge between them, and without it there is no bridge. ## Walking the timeline ```js async function doWork() { throw new Error('kaboom'); } async function run() { try { doWork(); // returns a rejected promise, throws nothing console.log('try finished'); } catch (err) { console.log('unreachable', err); } } run(); ``` Step by step: 1. `doWork()` is invoked. Its body runs synchronously up to its first `await` — here that is the whole body — and the `throw` is caught by the async function machinery itself. 2. Because an async function never throws synchronously to its caller, that error is used to **reject the promise the call returns**. The call expression completes normally, yielding a promise object. 3. The returned promise is discarded. Nothing in `run` is suspended on it. 4. `console.log('try finished')` runs; the try block ends; the catch clause is never entered because nothing was thrown. 5. `run` returns a promise that fulfils with `undefined` — from the caller's point of view, `run` **succeeded**. 6. On a later turn, the engine notices the rejected promise was never given a handler and reports an unhandled rejection. Step 5 is the damaging part. The failure is not merely logged in the wrong place: the enclosing operation reports success while the work it started failed. ## Why the block cannot help, even in principle A `catch` clause is attached to a region of the call stack. When an exception is thrown, the engine unwinds frames looking for the nearest enclosing handler. The rejection here happens after `run`'s try region has already been exited; there is no frame to unwind into. Adding another `try` further out does not help either — the rejection is not travelling up a stack at all, it is sitting in a promise's internal state waiting for someone to subscribe. `await` changes exactly this: it subscribes the async function's continuation to the promise and, on rejection, resumes the function by throwing the reason at the await point — inside the live try region. ## The three legitimate fixes ```js async function run() { try { await doWork(); // 1. wait here, catch here } catch (err) { /* ... */ } } async function run() { return doWork(); // 2. forward: caller's await sees the rejection } function run() { return doWork().catch(handle); // 3. attach a handler to the promise itself } ``` Option 2 deserves a note: returning the promise makes `run`'s own promise adopt its outcome, so a rejection is not lost — it just becomes the caller's problem, which is often correct. What is never correct is producing the promise and dropping it. ## The variants that trip people in review - **`forEach` with an async callback.** `items.forEach(async item => { await save(item); })` calls the callback once per item and discards every returned promise. The surrounding try/catch sees nothing, and the loop finishes before any save does. A `for...of` loop with `await` inside, or collecting the promises and awaiting them together, restores error visibility. - **A callback boundary.** Passing an async function where a non-promise-aware API expects a callback has the same shape: the API ignores the returned promise, so rejections have nowhere to go. - **Fire-and-forget with a comment.** `doWork(); // don't need the result` is fine only if you do not need the *failure* either — which is rarely true. If it really is fire-and-forget, attach a `.catch` that logs, so the failure is recorded deliberately rather than by the runtime's last-resort reporter. - **A missing `await` before `Promise.all`.** `try { Promise.all(tasks); }` has exactly the bug described, one level up. ## How you catch it in practice The symptom in production is an operation that reports success while its side effect never happened, plus an unhandled-rejection log line with no request context. Because the pattern is purely mechanical — a call expression that evaluates to a promise used as a statement — linters detect it reliably, and that is the right place to fix it: as a rule, not by reading every diff.
- Would adding an outer try/catch around the call to the enclosing async function catch it instead?No. The rejection is not propagating up a call stack at all — it is sitting in an orphaned promise with no subscriber. No `try` at any depth can intercept that, because nothing is ever thrown on a live execution path. Only awaiting, returning, or attaching a handler to that specific promise reaches it.
- Is `return doWork();` inside the try equivalent to `await doWork();` for error handling?Not for the local catch. `return doWork()` makes the enclosing function's promise adopt that outcome, so the failure reaches the *caller* rather than this function's catch clause — the try block is exited before the promise settles. Use `await` when this function must handle the error itself, plain `return` when it should forward it.
- What does `items.forEach(async item => await save(item))` do with a failure in save()?It loses it. `forEach` ignores the promise each async callback returns, so every rejection is unhandled and no surrounding try/catch sees it; the loop also completes before any save finishes. Use `for (const item of items) await save(item)` for sequencing, or await the collected promises together.
saying these in an interview costs you the question
- Thinks a rejected promise unwinds the call stack like a throw
- Believes an outer try/catch will catch it instead
- Says the try block waits for the async call to settle
- Treats an unhandled-rejection warning as a lint nit, not a lost error
- Adds a try/catch around forEach with an async callback and calls it fixed