Every sample below ran on Node 24.18, the current long-term support (LTS) line. Nothing here needs a flag or an experimental API, and most of it works several majors back, so it survives a Node 20 to 24 upgrade unchanged. I'm just telling you what I actually executed.
Quick take: wrap low-level errors with
new Error(msg, { cause })instead of rethrowing them bare, collect parallel failures withPromise.allSettledplusAggregateError, neverreturna promise from inside atrywithoutawait, type caught values asunknownand narrow withinstanceof, and treatuncaughtExceptionas a cleanup hook before exit, never as a recovery mechanism.
How Do Error.cause Chains Work?
You catch a low-level error, you throw a higher-level one, and the original stack vanishes. That was the standard Node failure story for a decade. The cause option fixes it: pass the original error as { cause } and the chain survives production logging intact, because util.inspect() (which console.error uses) recursively prints nested causes.
// config.mjs
import { readFile } from 'node:fs/promises';
async function loadConfig(configPath) {
try {
const raw = await readFile(configPath, 'utf8');
return JSON.parse(raw);
} catch (err) {
throw new Error(`Could not load config at ${configPath}`, { cause: err });
}
}
try {
await loadConfig('./missing.json');
} catch (err) {
console.error(err);
}
The output keeps both layers, including the original ENOENT code:
Error: Could not load config at ./missing.json
at loadConfig (file:///app/config.mjs:9:11) {
[cause]: [Error: ENOENT: no such file or directory, open './missing.json'] {
errno: -2,
code: 'ENOENT',
syscall: 'open',
path: './missing.json'
}
}
One habit worth keeping from the Node.js errors documentation: branch on error.code (ENOENT, ECONNREFUSED), never on error.message. Messages are allowed to change between Node versions; codes are the stable contract.
When Does AggregateError Show Up?
Two places. Promise.any() throws one when every input rejects, with the individual failures in err.errors:
try {
const res = await Promise.any([
fetch('https://eu.api.example.com/health'),
fetch('https://us.api.example.com/health'),
]);
console.log(res.status);
} catch (err) {
console.log(err instanceof AggregateError); // true
console.log(err.errors.length); // 2, one per failed endpoint
}
And you can construct your own, which is the pattern I'd actually push you toward. A batch job that reports only its first failure wastes everyone's time; collect all of them with Promise.allSettled, then throw once:
const results = await Promise.allSettled(jobs.map((job) => runJob(job)));
const failures = results
.filter((r) => r.status === 'rejected')
.map((r) => r.reason);
if (failures.length > 0) {
throw new AggregateError(failures, `${failures.length} of ${jobs.length} jobs failed`);
}
Whoever reads that log gets every root cause in one report instead of replaying the batch five times to shake out five errors sequentially. Is that worth the extra four lines? Ask anyone who has re-run a nightly import at 3am.
Which async/await Mistakes Actually Bite?
The one I flag most often in code review looks completely correct:
async function saveUser(user) {
try {
return db.insert(user); // no await: the catch below is dead code
} catch (err) {
log.warn('insert failed, retrying once');
return db.insert(user);
}
}
That retry branch has never once run. Why not? Because return db.insert(user) hands the pending promise straight to the caller, saveUser's stack frame is gone by the time the insert rejects, and the catch has nothing to catch. The fix is three keystrokes: return await db.insert(user). Inside a try, return await isn't a style nitpick, it's the difference between a working catch block and a decorative one. If the sequencing rules behind that are fuzzy, our async and await guide walks the same frames step by step.
The second pitfall crashes the whole process. Start two operations, then await them one at a time:
const first = fetchProfile(id); // starts immediately
const second = fetchOrders(id); // also starts immediately
const profile = await first; // throws here...
const orders = await second; // ...so this line never runs
If first rejects, your surrounding try/catch catches it, fine. But if second also rejects, nothing is listening. Node treats that as an unhandled rejection, and by default that's fatal:
node:internal/process/promises:391
triggerUncaughtException(err, true /* fromPromise */);
^
Error: orders service timed out
at fetchOrders (file:///app/orders.mjs:12:9)
code: 'ERR_UNHANDLED_REJECTION'
Exit code 1, no cleanup, mid-request. The fix is to hand both promises to a combinator, since Promise.all and Promise.allSettled subscribe to every input, so no rejection goes unobserved:
const [profile, orders] = await Promise.all([fetchProfile(id), fetchOrders(id)]);
Same shape, same concurrency, and one place for failures to land.
How Should You Type Errors in TypeScript?
With strict on (which includes useUnknownInCatchVariables since TypeScript 4.4), a caught value is unknown, and that's the honest type: JavaScript will let anyone throw a string. Touch .message on it and the compiler reports TS18046. That exact diagnostic is asserted, before and after, by unknown-catch.ts in the runnable typescript-strict-mode example folder that backs our strict mode guide. The workable pattern is a small error class hierarchy plus instanceof narrowing:
class UpstreamError extends Error {
constructor(
readonly status: number,
message: string,
options?: ErrorOptions,
) {
super(message, options);
this.name = 'UpstreamError';
}
}
async function payInvoice(invoiceId: string) {
try {
return await callBilling(invoiceId);
} catch (err) {
if (err instanceof UpstreamError && err.status === 429) {
return scheduleRetry(invoiceId);
}
throw err; // not ours to handle: let it climb
}
}
Note the return await again, and the rethrow. Catching an error you can't do anything about, just to log it and swallow it, is how systems fail silently. If your compiler isn't flagging untyped catch variables, check your config; our tsconfig generator turns the right strictness flags on by default.
Here's my opinionated bit: one try/catch per failure you can name a recovery for, and no more. A try/catch around every single await is worse than none at all, because it buries the two catches that matter under fifteen that just re-log and rethrow. That pile-up belongs on the same list as the other TypeScript code smells that read as diligence and function as noise.
What Belongs in Process-Level Handlers?
Very little, and the Node.js process docs say so bluntly: uncaughtException is a crude last resort, not an equivalent of On Error Resume Next. After one fires, the application is in an undefined state. What's actually safe there? Synchronous cleanup, then exit:
import fs from 'node:fs';
process.on('unhandledRejection', (reason) => {
throw reason; // escalate to the uncaughtException path below
});
process.on('uncaughtException', (err, origin) => {
// Synchronous work only: the event loop can't be trusted anymore.
fs.writeSync(process.stderr.fd, `${origin}: ${err?.stack ?? err}\n`);
process.exit(1);
});
What NOT to do: log and carry on. A handler that swallows the error keeps the process alive with half-finished state (a lock never released, a transaction never rolled back), and the corruption surfaces days later somewhere unrelated. Restarting belongs to an external supervisor, not to the wounded process itself. If you only want crash telemetry without changing crash behavior, that's exactly what process.on('uncaughtExceptionMonitor', ...) exists for: it fires before the crash handling runs and doesn't prevent the exit.
How Do You Shut Down Gracefully?
A graceful shutdown in Node.js is three obligations, in order: stop accepting new connections, let in-flight requests finish, and hard-exit on a deadline so a stuck socket can't hold the process open forever. Kubernetes sends SIGTERM and then kills the pod after terminationGracePeriodSeconds, 30 by default, so your own deadline has to land inside that window:
import http from 'node:http';
const server = http.createServer(app);
server.listen(3000);
let shuttingDown = false;
process.on('SIGTERM', () => {
if (shuttingDown) { return; }
shuttingDown = true;
server.close(() => {
process.exitCode = 0; // let the event loop drain on its own
});
server.closeIdleConnections(); // don't wait on idle keep-alive sockets
setTimeout(() => { process.exit(1); }, 10_000).unref();
});
Two details carry the weight here. Setting process.exitCode instead of calling process.exit() matters because exit() kills the process before asynchronous stdout writes flush, so your final log lines get truncated; the docs recommend setting the code and letting the loop empty naturally. And the .unref() on the timeout means the fallback timer won't itself keep the process alive once everything else has drained. I've seen the ten-second hard exit fire in practice exactly once, for a stuck socket, and that single truncated request beat the alternative: a pod that never terminated.
None of these patterns is clever, and that's the point. They survive on-call rotations because each failure mode above is reproducible, and each fix is small enough to apply in the same commit that found it.