Async Error Handling in Node.js Done Right
A rejected Promise nobody awaits is a silent failure waiting to become a 3am page; here's how to make every async error loud, catchable, and cancellable.
Most production incidents I've traced in Node services aren't logic bugs — they're errors that went somewhere the code never looked. A Promise rejects, nobody awaits it, the request hangs, and the only evidence is a process that quietly leaks memory until it falls over. Getting async error handling right is mostly about making failures loud and making sure every async path has an owner.
1. The three error channels: callbacks, Promises, async/await
Node has carried three error-propagation conventions over its lifetime, and a real codebase touches all three. You need to know how each surfaces a failure, because mixing them is where bugs hide.
Error-first callbacks are the original convention: the first argument is the error, and you must check it on every call. There is no language-level propagation — forget the check and the error vanishes.
fs.readFile('/etc/config.json', (err, data) => {
if (err) return done(err); // forget this line and the failure is gone
done(null, JSON.parse(data));
});
Promises move the error into a rejection that propagates down the chain until a .catch() handles it. The danger shifts: instead of forgetting an if (err), you forget to attach a handler at all.
async/await is syntactic sugar over Promises that lets rejections behave like synchronous throw, so ordinary try/catch works. This is the channel you want for new code — but the sugar hides a sharp edge we'll get to in section 3.
The crisp interview answer: callbacks propagate errors as a value you must inspect; Promises propagate them as a rejection that flows to the nearest .catch(); async/await turns that rejection back into a throwable so try/catch applies. An async function always returns a Promise, and any throw inside it becomes a rejection of that Promise.
2. The swallowed-rejection bug
Here's the bug I see most often. It looks correct, passes review, and works in every test — until the dependency fails in production.
async function handler(req, res) {
const user = await getUser(req.id);
// fire-and-forget: we don't await this
auditLog(user); // returns a Promise, rejection ignored
res.json(user);
}
async function auditLog(user) {
await db.insert('audit', { userId: user.id }); // throws when DB is down
}
When db.insert rejects, the rejection has no handler. The request still succeeds — res.json already ran — so monitoring stays green. Meanwhile Node emits an unhandledRejection, and since v15 the default behavior (the throw mode) is to print the error and terminate the process with a non-zero exit code. So your fire-and-forget audit call now intermittently crashes the server, and the stack trace points at auditLog with no clue which request triggered it.
The root cause is that auditLog(user) returns a Promise nobody owns. The try/catch you'd reach for doesn't help, because there's no await for it to wrap.
3. try/catch only catches what you await
This is the single most important mechanical fact about async error handling, and it trips up strong engineers. try/catch catches a rejection only if the rejecting Promise is awaited inside the try block. Fire-and-forget calls escape it entirely.
try {
fetchData(); // BUG: not awaited — rejection escapes the try
} catch (err) {
// never runs for fetchData's rejection
}
try {
await fetchData(); // correct: rejection is caught here
} catch (err) {
handle(err);
}
The same trap appears with callbacks and timers. A throw inside a setTimeout callback, or inside an error-first callback, runs on a future tick of the event loop — the surrounding try/catch is long gone from the stack, so the throw escapes to uncaughtException instead.
try {
setTimeout(() => { throw new Error('boom'); }, 0); // escapes try/catch
} catch (err) {
// never runs — different tick, empty stack
}
So the correct fix for the section-2 bug is to decide who owns the audit Promise. If the request shouldn't fail when auditing fails, own the rejection explicitly with its own handler — never leave it floating.
async function handler(req, res) {
const user = await getUser(req.id);
// fire-and-forget, but the rejection is owned
auditLog(user).catch((err) => logger.error('audit failed', err));
res.json(user);
}
If the request should fail when auditing fails, await it inside a try/catch instead. Either way the rule is the same: every Promise gets either an await in a try/catch or its own .catch().
4. unhandledRejection and uncaughtException: crash, don't swallow
Node gives you two last-resort process events. uncaughtException fires for a synchronous throw that reached the top of the stack with no handler; unhandledRejection fires for a Promise that is still rejected with no handler attached at the end of the event-loop turn in which it rejected. That timing is driven by V8's promise-rejection tracking (the HostPromiseRejectionTracker hook), not by garbage collection — so it is deterministic, not dependent on when the object is collected. If you attach a .catch() later, Node fires the companion rejectionHandled event so you can retract an earlier warning.
The instinct is to register handlers that log and continue — "keep the server up." That's a mistake. By the time these fire, you are in undefined territory: a connection may be half-released, a transaction half-committed, an invariant violated. Continuing risks corrupting more state on top of the original fault. The official guidance, and the right one, is to log, fire off any synchronous cleanup, and let the process exit so your supervisor (systemd, Kubernetes, PM2) restarts it clean.
process.on('unhandledRejection', (reason) => {
logger.fatal({ reason }, 'unhandledRejection — crashing');
throw reason; // promote to uncaughtException for one exit path
});
process.on('uncaughtException', (err) => {
logger.fatal({ err }, 'uncaughtException — crashing');
// synchronous cleanup only; async cleanup may never run
process.exit(1);
});
Note that registering an unhandledRejection listener overrides the default crash, so you must re-introduce the exit yourself — here by re-throwing reason, which surfaces as an uncaughtException. Use these as crash-with-context, not as a net to swallow bugs. The interview point: uncaughtException is the synchronous escape hatch, unhandledRejection the asynchronous one, and the only safe action in either is structured logging followed by a clean exit. Anything else degrades from "one failed request" to "a server in an unknown state serving everyone."
5. Promise.all vs allSettled: pick the right failure semantics
When you fan out concurrent work, the combinator you choose is your error policy. Promise.all is fail-fast: it rejects the instant any input rejects, with that first error, and ignores the rest of the results.
// all-or-nothing: one failure fails the whole batch
const [user, prefs, perms] = await Promise.all([
getUser(id), getPrefs(id), getPermissions(id),
]);
That's correct when you genuinely need every result — there's no point rendering a page missing the user object. But two subtleties bite people. First, Promise.all does not cancel the other operations when one rejects; they keep running, and any rejections they produce later become unhandled rejections unless those promises already carry their own handlers. Second, if multiple inputs reject, you only see the first.
Promise.allSettled never rejects. It waits for every input to settle and returns an array of { status: 'fulfilled', value } or { status: 'rejected', reason }, letting you handle partial success deliberately.
const results = await Promise.allSettled([
sendEmail(user), sendPush(user), sendSms(user),
]);
const failed = results.filter((r) => r.status === 'rejected');
if (failed.length) {
logger.warn({ failed }, 'some notifications failed');
}
// the successful sends still went out
Rule: use all when partial results are useless and you want to abort early; use allSettled for independent best-effort work where one failure shouldn't sink the others. (Promise.any is the third option — first fulfillment wins, rejecting only if all inputs reject, in which case it rejects with an AggregateError whose .errors array holds every reason — useful for racing redundant sources.)
6. AbortController for cancellation and timeouts
A rejection you handle is only half the story; the other half is not leaving work running that nobody's waiting for. AbortController is the standard, composable cancellation primitive. You create a controller, pass its signal into any signal-aware API (fetch, much of fs/http, timers), and call abort() to cancel. A signal-aware operation rejects with whatever value is in signal.reason: when you call abort() with no argument the reason defaults to a DOMException named AbortError, and when you call abort(myError) it is exactly the value you passed.
async function fetchWithTimeout(url, ms) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), ms); // default reason: AbortError
try {
return await fetch(url, { signal: controller.signal });
} finally {
clearTimeout(timer); // always clear, success or failure
}
}
Node ships helpers so you rarely hand-roll this. AbortSignal.timeout(ms) gives you a signal that aborts itself — but note its reason is a DOMException named TimeoutError, not AbortError, precisely so you can tell a deadline apart from a manual cancel. AbortSignal.any([...signals]) composes several — for example, abort when either a timeout fires or the client disconnects — and the composite takes on the reason of whichever source fired first.
const signal = AbortSignal.any([
AbortSignal.timeout(5000), // server-side deadline -> TimeoutError
req.signal, // client went away -> AbortError
]);
const data = await fetch(upstreamUrl, { signal });
Distinguish a deliberate abort from a real failure by inspecting the error: check err.name against 'AbortError' and 'TimeoutError', or compare against signal.reason directly — neither is usually an error you alert on. The finally block matters: clearing the timer prevents a leaked handle and avoids aborting an already-finished request. Cancellation closes the loop opened by good error handling: errors get owned, and abandoned work gets stopped.
Rules of thumb
- Every Promise needs an owner: either
awaitit inside atry/catch, or attach its own.catch(). A floating Promise is a latentunhandledRejection. try/catchcatches a rejection only if youawaitthe rejecting Promise inside it; fire-and-forget calls, timer callbacks, and error-first callbacks all escape it.- Treat
unhandledRejectionanduncaughtExceptionas crash-with-context, not as recovery — log, do synchronous cleanup, exit, and let your supervisor restart clean. Remember that registering a handler overrides the default crash, so add the exit back yourself. - Choose the combinator as a policy:
Promise.allto fail fast when you need every result,Promise.allSettledfor best-effort independent work,Promise.anyto race redundant sources. - Make timeouts and cancellation first-class with
AbortSignal.timeoutandAbortSignal.any, clean up timers infinally, and treat bothAbortErrorandTimeoutError(or whatever you pass toabort(reason)) as expected, not as an alert.