You catch an error inside an async JavaScript function so you can log it, but the caller still needs to know the operation failed. What are your options for passing the failure on, and what does each do to the promise that async function returns?
answer
- catching consumes the error
- swallowing fulfils with undefined
- rethrow to keep the caller informed
- wrap with the cause option
- conditional handling needs a trailing throw
basics
~20 sCatching without rethrowing makes the async function fulfil, so the caller sees success. To propagate, either rethrow the original error, throw a new error carrying the original as its cause, or return a rejected promise — all three reject the returned promise.
solid answer
~50 sA `catch` clause consumes the error. Whatever the block returns becomes the fulfilment value of the async function's promise, so "log and move on" silently converts a failure into a success — the classic bug where a caller gets `undefined` instead of an exception. To keep the failure visible you have three equivalent options: `throw err` rethrows the original untouched, preserving its stack and type so upstream `instanceof` checks still work; `throw new Error('loading config failed', { cause: err })` wraps it with the context of this layer while keeping the original reachable through `.cause` (the options bag is ES2022); or `return Promise.reject(err)`, which is the same outcome in expression form. I reach for wrapping at layer boundaries, where the caller needs to know *which* operation failed, and plain rethrow inside a layer, where adding a frame adds nothing. What I avoid is catching to log and rethrowing at every level — that produces the same failure logged five times with no extra information.
code
javascript · 16 linesasync function readConfig() {
throw new Error('ENOENT: config.json');
}
async function start() {
try {
return await readConfig();
} catch (err) {
throw new Error('startup failed', { cause: err });
}
}
start().catch(err => {
console.log(err.message); // startup failed
console.log(err.cause.message); // ENOENT: config.json
});go deeper
Know that catching an error stops it: if the caller must find out, the catch block has to throw again. Recognise that a function which logs and returns nothing looks successful to its caller.
Explain what each option does to the returned promise and why rethrowing the original preserves type and stack, so upstream instanceof or err.code checks still work. Know that { cause } keeps the original reachable.
Show judgment about where wrapping earns its keep versus adding a hop, insist on the trailing rethrow in selective handlers, and explain why log-and-rethrow at every layer degrades incident triage rather than improving it.
Own the error taxonomy across service boundaries: which layers may wrap, whether callers branch on types or codes, where the single logging point sits, and how that contract survives refactors without every layer inventing its own convention.
## What a catch clause does to the returned promise An async function's promise settles from how the body finishes. Normal completion or `return v` fulfils it with `v`; an uncaught exception rejects it. A `catch` clause is a normal completion path — so once you catch, the error is *gone* unless you deliberately re-signal it: ```js async function loadConfig() { try { return await readFile('config.json'); } catch (err) { logger.error('config read failed', err); // logged... } // ...and swallowed } ``` `loadConfig()` fulfils with `undefined`. The caller's `await` succeeds, and the `undefined` propagates into code that expected a config object, usually producing a `TypeError` several frames away from the real cause. This is the single most consequential decision in the block: consume or propagate. ## The three ways to propagate **Rethrow the original.** ```js catch (err) { logger.error('config read failed', err); throw err; } ``` The returned promise rejects with the exact same value. Type is preserved, so a caller's `err instanceof FileNotFoundError` or `err.code === 'ENOENT'` still works, and the stack still points at where the error was constructed. Use this when this layer adds no information the caller lacks. **Wrap with a cause.** ```js catch (err) { throw new Error(`loading ${path} failed`, { cause: err }); } ``` The `{ cause }` options bag is ES2022 and sets the new error's `cause` property to the original, so nothing is lost: the caller reads `err.message` for this layer's context and `err.cause` for the underlying failure. Modern runtimes print the cause chain when logging the outer error. The tradeoff is that the outer error is a plain `Error` — callers that were branching on the inner type now have to look at `err.cause`, so wrapping is a contract change at that boundary, not a free improvement. **Return a rejected promise.** ```js catch (err) { return Promise.reject(err); } ``` Identical outcome to `throw err` in an async function, occasionally handier inside an expression. `throw` reads better and is the convention. ## Choosing between them The useful heuristic is *does this layer know something the caller does not*. A repository function that knows the query, the table, and the parameters can turn `ECONNRESET` into `"loadUser(42) failed"` with the original as the cause — real added context. A pass-through helper that just forwards a call knows nothing extra; wrapping there adds a message that says the same thing twice and buries the real error one level deeper. Every wrap layer costs one `.cause` hop for whoever reads the log. The related anti-pattern is log-and-rethrow at every level. Each layer logs the same failure, so one incident produces five stack traces with the same root and no additional facts. Pick one place — usually the outermost boundary that has request context — to log, and let the intermediate layers either wrap silently or not catch at all. ## Conditional rethrow JavaScript has no typed catch clauses, so selective handling is a branch inside the block, and the `else` branch must rethrow: ```js catch (err) { if (err instanceof NotFoundError) return null; // handled: legitimate absence throw err; // everything else propagates } ``` Forgetting the trailing `throw err` is how a broad `catch (err) { return null; }` turns a database outage into an empty result set. The same applies to `catch` blocks that inspect `err.code`: unrecognised codes must escape. ## Rethrowing from a finally-adjacent position If cleanup lives in a `finally` next to this `catch`, remember that a throw from the `finally` block replaces whatever the `catch` rethrew. Best-effort teardown belongs in its own try/catch so the error you deliberately propagated is the one that survives. ## What the caller sees Whichever option you choose, the caller's experience is uniform: their `await` throws, their `try`/`catch` runs, their `.catch` fires. That uniformity is the point — an async function has exactly one failure channel, its returned promise, and the only question the catch clause answers is whether a failure is put back into it. ## Non-Error values If the caught value is not an `Error` — a string, a rejected response object — wrapping is also the moment to normalise it: `throw new Error('upstream failed', { cause: err })` gives downstream handlers a real stack and message regardless of what the library rejected with. Rethrowing verbatim preserves the oddity for everyone above you.
- When would you rethrow the original error rather than wrapping it?When this layer adds no information the caller lacks, and when callers branch on the error's type or code. Rethrowing preserves the class, the properties, and the original stack, so `instanceof` checks and `err.code` tests upstream keep working. Wrapping is for boundaries where naming the failed operation genuinely helps diagnosis.
- What goes wrong with catching, logging, and rethrowing at every layer?One incident produces a stack trace per layer, all with the same root cause and no new facts, which makes logs noisier and incident triage slower. Log once at the boundary that owns request context; intermediate layers should either wrap with real added context or not catch at all.
- How should a catch block that only handles one specific failure be written?Branch on the specific case and rethrow everything else: `if (err instanceof NotFoundError) return null; throw err;`. JavaScript has no typed catch clauses, so without that trailing throw a narrow handler quietly absorbs unrelated failures — a database outage becomes an empty result rather than an error.
saying these in an interview costs you the question
- Logs the error and returns undefined, calling it handled
- Thinks a catch block automatically rethrows if it returns nothing
- Believes wrapping loses the original error
- Catches, logs, and rethrows identically at every layer
- Writes a broad catch that returns a default with no conditional rethrow