Skip to content

Error Handling in Node.js: Patterns, Pitfalls, Fixes

Error.cause chains, AggregateError, unhandledRejection pitfalls, typed errors in TypeScript, and graceful shutdown, each with runnable Node.js code.

· · 8 min read
Lines of source code displayed on a dark computer screen

Quick Take

The error handling bugs I flag most in code review aren't exotic. They're the same four or five patterns, misused the same way, and each one has a failure mode you can reproduce in ten lines. Here are those ten-line reproductions, plus the fixes.

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 with Promise.allSettled plus AggregateError, never return a promise from inside a try without await, type caught values as unknown and narrow with instanceof, and treat uncaughtException as 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.

A laptop screen showing JavaScript source code in a dark editor theme
Photo by Behnam Norouzi on Unsplash

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.

A desk with two monitors showing code, lit by warm ambient light at night
Photo by Fotis Fotopoulos on Unsplash

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.

Frequently Asked Questions

Should I use process.on('uncaughtException') to keep my app running?
No. The Node.js docs are explicit that uncaughtException is a last resort for synchronous cleanup (flushing a log, closing file descriptors) before exiting, not a way to resume normal operation. After an uncaught exception the process is in an undefined state, and continuing from there can corrupt data in ways that surface much later. Log the error, exit with a nonzero code, and let a supervisor (systemd, Kubernetes, PM2) restart the process cleanly.
What's the difference between Promise.all and Promise.allSettled for error handling?
Promise.all rejects as soon as any input promise rejects, and you only ever see that first error. Promise.allSettled never rejects: it waits for every promise and returns an array of { status, value } or { status, reason } objects, so you can inspect every failure individually. Use allSettled when partial success is acceptable (batch jobs, notifications) and all when the results are only useful together. Both attach handlers to every input promise, so neither leaks unhandled rejections.
Why is the error in a TypeScript catch block typed as unknown?
Because JavaScript lets you throw anything, not just Error instances: a string, a number, undefined. With useUnknownInCatchVariables (on by default under strict since TypeScript 4.4), the compiler forces you to narrow before touching properties, typically with instanceof checks. That's accurate to the runtime reality. Code that assumes every caught value has a .message property will eventually meet a library that throws something else.