Asyncintermediate
Error Handling in Async Code
Strategies for catching and handling errors across callbacks, promise chains, and async/await, including unhandled rejections.
Errors cross asynchronous boundaries differently in each style, and the failure is usually silence rather than a crash.
Handling async errors is a relay of responsibility: error-first callbacks are runners who must check for a dropped baton themselves; a promise chain's .catch is a catcher at the end of the race; try/catch pauses the race the instant a runner trips.
Key Concepts
1
try/catch does not reach into a callback.
try {
setTimeout(() => { throw new Error('boom'); });
} catch (e) {
// never runs -- the callback runs later, on an empty stack
}try/catch
2
By the time the callback executes, the try block has long returned. The error becomes an uncaught
exception.
try
3
Promises: one catch covers the chain.
fetchUser(id)
.then(u => fetchOrders(u.id))
.then(render)
.catch(handle); // catches any of the abovecatch
4
Order matters. A catch before a then does not cover it.
catchthen
5
async/await restores try/catch.
try {
const u = await fetchUser(id);
render(await fetchOrders(u.id));
} catch (e) {
handle(e);
} finally {
hideSpinner();
}async/awaittry/catch
6
The unhandled rejection is the important failure mode.
async function save() { throw new Error('boom'); }
save(); // no await, no catch -- unhandled rejection7
Nothing in the surrounding code fails. In Node this terminates the process by default in current versions;
in a browser it logs and continues, so a save that never happened looks like nothing at all.
8
window.addEventListener('unhandledrejection', e => log(e.reason));
process.on('unhandledRejection', err => { log(err); exit(1); });9
Registering that handler is how you find these at all.
10
Calling an async function without awaiting it is the usual cause. If you deliberately do not want to
wait, say so:
11
void save().catch(log);12
A rejection inside Promise.all loses the other results. Use allSettled when partial success matters.
Promise.allallSettled
13
Errors in a finally replace the original, which hides the real cause:
finally
14
try { throw new Error('real'); }
finally { throw new Error('mask'); } // 'real' is lost15
And the practical rule. Every promise chain ends in a catch, every await sits in a try, or the
caller handles it. An async call with no handler anywhere in the chain is a silent failure waiting for
production.
catchawaittry
16
What the interviewer is probing.1. "Why does try/catch around a callback not catch its error?" Probing: the stack boundary.
Stalls: "It should." Moves up: the callback runs later on a different stack, so the enclosing
try has already exited; promises and await restore that connection.
17
2. "What happens to an error thrown inside .then?" Probing: propagation. Stalls: "It is
swallowed." Moves up: it rejects the returned promise and skips to the next catch in the chain.
18
3. "Where should catch go in a chain?" Probing: placement. Stalls: "After each then." *Moves
up:* at the end to cover everything, with an earlier catch only where you want to recover and
continue.
19
4. "How do you keep the original error when rethrowing?" Probing: the modern feature.
Stalls: "Concatenate the message." Moves up: throw a new Error with the cause option, which
preserves the stack of the original.