Error handling in Node applications

Node.js · Practical · 13 min

How a service fails decides whether an incident is a logged warning or a crashed, corrupted process.

Two categories of error need different treatment:

  • Operational errors are expected failures of a correct program: file not found, connection refused, invalid user input, a timeout. Handle them — retry, return a 4xx, fall back to defaults.
  • Programmer errors are bugs: undefined is not a function, a wrong argument type. You can't meaningfully recover; log, crash, and let a supervisor restart the process.

Node's errors carry a stable `code` property (ENOENT, ECONNREFUSED, ERR_INVALID_ARG_TYPE). Branch on err.code, never on message text, which can change between versions. Do the same in your own code: subclass Error, set name and a code, and add context fields.

When you catch and rethrow, keep the original with `cause`: throw new Error("loading user 42 failed", { cause: err }). Logs and util.inspect then show both stacks.

Async rules:

  • try/catch only catches a rejection if you await the promise inside the try.
  • A promise rejection nobody handles triggers unhandledRejection; since Node 15 the default is to crash with exit code 1.
  • A throw that escapes everything triggers uncaughtException and also crashes.

You can listen for both on process, but use them only to log and exit (process.exit(1) after flushing logs). After an uncaught exception, the process may be in an inconsistent state — half-written data, held locks — so continuing is dangerous. Run under something that restarts you (systemd, Docker restart policy, Kubernetes).

Finally, throw Error objects, not strings: only errors have stack traces.

Example

import { readFile } from "node:fs/promises";

class NotFoundError extends Error {
  name = "NotFoundError";
  code = "E_NOT_FOUND";
  constructor(what, options) { super(`${what} not found`, options); }
}

async function loadUser(id) {
  try {
    return JSON.parse(await readFile(`users/${id}.json`, "utf8"));
  } catch (err) {
    if (err.code === "ENOENT") throw new NotFoundError(`user ${id}`, { cause: err });
    throw new Error(`loading user ${id} failed`, { cause: err });
  }
}

await loadUser(42).catch((err) => {
  console.log(err.code, "-", err.message, "| cause:", err.cause.code);
  // E_NOT_FOUND - user 42 not found | cause: ENOENT
});

process.on("unhandledRejection", (reason) => {
  console.error("unhandled rejection", reason);
  process.exit(1);            // log, then crash; let the supervisor restart us
});

Remember

  • Handle operational errors; crash and restart on programmer errors
  • Branch on err.code, wrap with { cause } to keep context
  • Unhandled rejections crash Node by default
  • Use uncaught handlers only to log and exit

Watch out for

  • Catching uncaughtException and continuing to serve requests from a corrupted state.
  • try { promise } catch without await, so the rejection escapes.

Check yourself

  1. Which error should a service typically handle and keep running?
  2. Why is checking err.code better than matching err.message?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: nodejs.org/docs/latest-v22.x/api/errors.html