Error handling in Node looks simple once you have async/await: wrap the call in try/catch and move on. The trouble is that “the call” and “the error” don’t always line up. An error can surface after the function that started the work has returned, in a timer, in an event, or in a promise nobody is waiting on. When that happens, your catch never runs and the error goes to the process instead, which in modern Node usually means the process exits.
Interviewers test this with code-reading questions (“will the catch block run?”) and design questions (“where should errors be handled in a service?”). Both come down to the same idea: an error is handled by whoever is waiting for the operation, so you have to know who that is.
Three ways an error slips past try/catch
try/catch in an async function catches synchronous throws and rejections of promises you await inside the try. Anything else escapes. Here are two of the classic cases side by side:
async function loadA() { throw new Error('A failed'); }
async function withoutAwait() {
try { return loadA(); } // promise returned, not awaited
catch (err) { return 'handled: ' + err.message; }
}
async function withAwait() {
try { return await loadA(); }
catch (err) { return 'handled: ' + err.message; }
}
console.log(await withAwait());
try { console.log(await withoutAwait()); }
catch (err) { console.log('escaped the try/catch:', err.message); }
async function scheduleLater() {
try {
setTimeout(() => { throw new Error('thrown in a timer'); }, 0);
} catch (err) {
console.log('never printed', err);
}
}
process.on('uncaughtException', (err) => console.log('uncaughtException:', err.message));
await scheduleLater();handled: A failed
escaped the try/catch: A failed
uncaughtException: thrown in a timerreturn promise versus return await promise. In withoutAwait, the try block hands the promise back without waiting for it, so the function leaves the try before the rejection happens. The caller gets the rejection instead. With return await, the rejection happens while execution is still inside the try. This is why the old lint rule against “redundant” return await was wrong inside try blocks, and why typescript-eslint’s return-await rule now requires it there.
Callbacks scheduled for later. The timer callback runs on a later turn of the event loop, long after scheduleLater’s try block has finished. There’s no stack connecting them, so the throw goes to uncaughtException. The same applies to callbacks given to older APIs like fs.readFile(path, cb) or a library’s on('data') handler. The fix is to convert the callback into a promise (fs.promises.readFile, util.promisify, or events.once) so the error flows back to an await.
Events. An EventEmitter that emits 'error' with no listener throws it immediately, which for a socket or stream usually means an uncaught exception from deep in Node’s internals:
node:events:497
throw er; // Unhandled 'error' event
^Every stream, socket, child process and client object you create needs an 'error' listener, or needs to be used through an API (like stream/promises pipeline) that attaches one and turns it into a rejection.
The floating promise
The third case deserves its own section because it’s the one that takes services down. Here a signup handler sends a welcome email without awaiting it, so the response isn’t delayed:
async function sendWelcomeEmail() { throw new Error('SMTP 421 try again later'); }
async function signup() {
sendWelcomeEmail(); // floating promise
return 'created';
}
console.log(await signup());
setTimeout(() => console.log('still serving?'), 50);created
file:///app/err2.mjs:1
async function sendWelcomeEmail() { throw new Error('SMTP 421 try again later'); }
^
Error: SMTP 421 try again later
at sendWelcomeEmail (file:///app/err2.mjs:1:43)
at signup (file:///app/err2.mjs:3:3)
...
Node.js v22.23.2(Paths are shortened; the shell then reported exit code 1.) The signup succeeded, and then the whole process died because of an email. still serving? never printed. Since Node 15, the default --unhandled-rejections=throw mode treats a rejection with no handler as an uncaught exception. On a server, that one failed email takes every in-flight request down with it.
A promise needs an owner. If the work really is fire-and-forget, own it explicitly: sendWelcomeEmail(user).catch((err) => log.warn({ err }, 'welcome email failed')). Better still for anything that matters, put it on a job queue with retries. The @typescript-eslint/no-floating-promises lint rule catches the missing await at review time, and it’s one of the highest-value rules you can turn on in a Node codebase.
Adding context without losing the original
When a low-level error crosses a layer, the message it carries is usually too specific to be useful on its own (connect ECONNREFUSED 10.0.3.7:5432 doesn’t say which business operation failed) and the higher-level message is too vague without it. Error accepts a cause option, supported since Node 16.9, so you can have both:
class NotFoundError extends Error {
constructor(message, options) { super(message, options); this.name = 'NotFoundError'; this.status = 404; }
}
async function loadInvoice(id) {
let row;
try {
row = await repository.load(id);
} catch (err) {
throw new Error(`loading invoice ${id} failed`, { cause: err });
}
if (!row) throw new NotFoundError(`invoice ${id} not found`);
return row;
}console.error prints the whole chain, with the original under a [cause]: label, including its code: 'ECONNREFUSED'. Structured loggers like pino serialise cause as well. Compare that with the common anti-pattern throw new Error('loading invoice failed: ' + err.message), which keeps the text but throws away the original stack, code and any other properties that callers or retry logic check.
Notice the try wraps only the repository call. A NotFoundError thrown below it isn’t wrapped into a generic failure. Keep try blocks tight around the operation whose errors you mean to translate.
Handling errors at the boundary
Most functions shouldn’t catch errors at all. They should let them propagate (perhaps adding a cause on the way) to a boundary: the place where one unit of work started and where a decision can be made about the result. In a web service that’s the request handler; in a worker it’s the job; in a consumer it’s the message.
async function handle(id) {
try {
return { status: 200, body: await loadInvoice(id) };
} catch (err) {
if (err.status) return { status: err.status, body: { error: err.message } };
console.error('request failed:', err.message, '| cause:', err.cause?.code);
return { status: 500, body: { error: 'internal error' } };
}
}
console.log(await handle('missing'));
console.log(await handle('inv-42'));{ status: 404, body: { error: 'invoice missing not found' } }
request failed: loading invoice inv-42 failed | cause: ECONNREFUSED
{ status: 500, body: { error: 'internal error' } }The boundary separates expected errors, which carry a status and a message that’s safe to show, from unexpected ones, which get logged with full detail and returned as a generic 500. The client never sees 10.0.3.7:5432.
Frameworks give you this boundary if you use it correctly. Fastify and Express 5 catch rejected promises from async handlers and route them to the error handler. Express 4 doesn’t: an async handler that rejects there becomes an unhandled rejection, which is one of the most common causes of mysterious Node crashes in older codebases.
Above every boundary sits the process. process.on('uncaughtException') and process.on('unhandledRejection') are for logging and a fast, clean exit, not for recovery. After an unexpected throw from somewhere unknown, the process may hold half-updated state, a leaked connection or a lock, and the documented guidance is to treat it as unsafe. Log the error, stop accepting traffic, and let your supervisor (systemd, Kubernetes) start a fresh process.
Interview practice
What does this print, and does the process keep running? How would you fix it?
async function audit(event) { throw new Error('audit store down'); }
async function transfer() {
try {
audit('transfer');
return 'ok';
} catch (err) {
return 'audit failed';
}
}
transfer().then(console.log);Answer
It prints ok, and then the process crashes with Error: audit store down and exit code 1. audit returns a rejected promise immediately; the try block doesn’t await it, so no exception is thrown inside the try and the catch can’t run. The function returns 'ok', and when the microtask queue drains, Node finds a rejected promise with no handler and, under the default throw mode since Node 15, raises it as an uncaught exception.
The fix depends on what the audit means. If a transfer must not succeed without an audit record, write await audit('transfer') inside the try, so the rejection is caught and the caller sees 'audit failed' (and the transfer should be rolled back or not committed). If auditing is best-effort, handle it explicitly: audit('transfer').catch((err) => log.error({ err }, 'audit failed')). Either way, enabling no-floating-promises would have flagged the line.