Error Handling in Node.js

Things go wrong: files go missing, networks time out, users send bad input, and code has bugs. In Node.js, how you handle these failures decides whether your program degrades gracefully or crashes the entire process — taking down every other request it happened to be serving. Because Node runs on a single-threaded event loop shared by all your requests and timers, error handling isn’t just about catching exceptions; it’s about knowing which errors are recoverable, which are fatal, and where in Node’s async machinery an error can actually surface. This lesson covers try/catch, error-first callbacks, rejected promises, EventEmitter error events, custom Error classes, and the process-level safety nets Node gives you as a last resort.

Overview: How Errors Work in Node.js

Node.js has four distinct places an error can appear, and each one needs a different handling strategy.

Synchronous errors. Code that throws immediately — a bad JSON.parse, a null property access — is caught with an ordinary try/catch, exactly like in browser JavaScript. The error propagates up the call stack until something catches it or it reaches the top and crashes the process.

Errors in callbacks. Node’s older APIs (like the callback form of fs.readFile) use the error-first callback convention: the callback’s first argument is either an Error object or null. This convention exists because try/catch cannot catch errors from asynchronous callbacks — by the time the callback actually runs, the original call has already returned and its stack frame, including any surrounding try block, is gone. The only way to report failure is to pass the error as data.

Errors in promises. A rejected promise is just a value flowing through .then/.catch, or, with async/await, an object that await re-throws into your normal try/catch. This is why modern Node code that uses fs/promises, fetch, and other promise-based APIs reads almost like synchronous error handling.

Errors from EventEmitter and streams. Every EventEmitter (and every stream, since streams are emitters) treats the event name "error" specially. If you call emitter.emit("error", err) and no "error" listener is registered, Node throws that error synchronously out of emit(). With nothing to catch it, it becomes an uncaught exception and crashes the whole process. This is one of the most common ways beginners accidentally take down a Node server.

Node’s community convention (from the original Joyent error-handling guidelines, still followed today) also distinguishes two categories of error: operational errors are expected failures in a working system — a missing file, a timed-out request, invalid user input — and should be caught and handled. Programmer errors are bugs — calling a function with the wrong type, a typo in a property name — and generally should not be “handled” by continuing execution, because the process is now in an unknown state. The safest response to a programmer error is usually to log it and let the process exit.

Syntax

The core patterns you’ll use repeatedly:

try {
  // synchronous code, or an awaited promise
} catch (err) {
  // handle or rethrow err
} finally {
  // always runs, sync or async
}

function callbackStyle(arg, callback) {
  // callback(err, result)
}

class MyError extends Error {
  constructor(message, options) {
    super(message, options); // options.cause is supported since Node 16.9
    this.name = "MyError";
  }
}
Part Meaning
err.message Human-readable description of what went wrong.
err.name The error’s type/class name, e.g. "TypeError" or a custom name.
err.stack A string with the message plus the call stack at creation time — for logs, not for users.
err.cause Optional wrapped original error, set via new Error(msg, { cause }).
err.code On Node’s built-in errors (like fs errors), a stable string such as "ENOENT" you can branch on.

Examples

Example 1: try/catch with async/await and fs/promises

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

async function loadConfig(path) {
  try {
    const raw = await readFile(path, "utf8");
    return JSON.parse(raw);
  } catch (err) {
    if (err.code === "ENOENT") {
      throw new Error(`Config file not found: ${path}`, { cause: err });
    }
    throw err;
  }
}

async function main() {
  try {
    const config = await loadConfig("./config.json");
    console.log("Loaded config:", config);
  } catch (err) {
    console.error("Failed to start:", err.message);
    process.exitCode = 1;
  }
}

main();
Failed to start: Config file not found: ./config.json

Because ./config.json doesn’t exist, readFile‘s promise rejects with an error whose code is "ENOENT". The inner catch recognizes that specific case and rethrows a friendlier error, attaching the original via cause so nothing is lost. The outer catch in main logs it and sets process.exitCode instead of letting the process crash with a raw stack trace.

Example 2: the error-first callback pattern

import fs from "node:fs";

fs.readFile("./data.txt", "utf8", (err, data) => {
  if (err) {
    console.error("Could not read file:", err.message);
    return;
  }
  console.log("File contents:", data);
});
Could not read file: ENOENT: no such file or directory, open './data.txt'

Every callback-based Node API follows this shape: check err first and return early if it’s set, otherwise use the result. Forgetting the if (err) check is a classic bug — the code will happily try to use data even though the read failed, usually crashing later with a much more confusing error.

Example 3: custom Error classes and EventEmitter errors

import { EventEmitter } from "node:events";

class ValidationError extends Error {
  constructor(message, field) {
    super(message);
    this.name = "ValidationError";
    this.field = field;
  }
}

function validateAge(age) {
  if (typeof age !== "number" || age < 0) {
    throw new ValidationError("Age must be a non-negative number", "age");
  }
  return age;
}

try {
  validateAge(-5);
} catch (err) {
  if (err instanceof ValidationError) {
    console.error(`Validation failed on "${err.field}": ${err.message}`);
  } else {
    throw err;
  }
}

const emitter = new EventEmitter();

emitter.on("error", (err) => {
  console.error("Emitter reported an error:", err.message);
});

emitter.emit("error", new Error("something went upstream"));
Validation failed on "age": Age must be a non-negative number
Emitter reported an error: something went upstream

Custom error classes let callers branch on instanceof instead of parsing message strings, and let you attach extra structured data (here, field). The emitter.on("error", ...) listener is not optional decoration — without it, the emit("error", ...) call on the last line would throw and crash the process.

Under the Hood: Where Each Kind of Error Actually Surfaces

It helps to trace exactly what Node does when something fails:

  • Synchronous throw: execution unwinds the current call stack frame by frame until it finds an enclosing try/catch. If none exists, Node emits process‘s "uncaughtException" event; if nothing handles that either, the process prints the stack trace and exits with a non-zero code.
  • Rejected promise: if you await it inside a try, the rejection is thrown into that catch just like a synchronous error. If nothing ever attaches a handler (no .catch, no surrounding try/await) before the microtask queue finishes processing it, Node emits process‘s "unhandledRejection" event.
  • Callback error: the error is simply the first argument passed to your callback function whenever the operation completes, on a later turn of the event loop. There is no stack unwinding involved — it’s a plain function call with data.
  • EventEmitter/stream error: emit("error", err) checks synchronously, at emit time, whether any "error" listener is attached. If not, it throws err right there, which then behaves like any other synchronous throw with no try/catch around it — straight to "uncaughtException".

The two process-level events are true last resorts:

process.on("unhandledRejection", (reason) => {
  console.error("Unhandled rejection:", reason);
  process.exitCode = 1;
});

process.on("uncaughtException", (err) => {
  console.error("Uncaught exception, shutting down:", err.message);
  process.exit(1);
});

Node’s own documentation recommends treating "uncaughtException" as a place to log and perform emergency cleanup before exiting — not as a way to keep the process alive. Once a synchronous exception has escaped every try/catch in your code, internal state may be inconsistent, and continuing to serve requests risks silent data corruption.

Common Mistakes

Mistake 1: no “error” listener on an emitter or stream

import { EventEmitter } from "node:events";

const emitter = new EventEmitter();
emitter.emit("error", new Error("disk full")); // no "error" listener registered anywhere

This throws immediately and crashes the process, because "error" is a special event name in EventEmitter. The fix is to always register a listener before anything can emit an error:

import { EventEmitter } from "node:events";

const emitter = new EventEmitter();
emitter.on("error", (err) => {
  console.error("Handled:", err.message);
});
emitter.emit("error", new Error("disk full"));

Mistake 2: firing off an async call without awaiting or catching it

async function riskyOperation() {
  throw new Error("network timeout");
}

function run() {
  riskyOperation(); // missing await/catch — rejection is unhandled
  console.log("Continuing as if nothing happened...");
}

run();

The rejection here has no handler anywhere, so Node reports an "unhandledRejection" — in recent Node versions this terminates the process by default. Always await the call inside a try, or attach .catch:

async function riskyOperation() {
  throw new Error("network timeout");
}

async function run() {
  try {
    await riskyOperation();
  } catch (err) {
    console.error("Operation failed:", err.message);
  }
}

run();
Operation failed: network timeout

Other mistakes worth knowing

Empty catch blocks. catch (err) {} silently discards failures, which turns real bugs into mysteries later. At minimum, log the error before deciding to ignore it.

Treating every error as recoverable. Catching a programmer error (like a TypeError from calling a method on undefined) and quietly continuing often masks corrupted state instead of fixing anything — only operational errors should be routinely “handled and continue.”

Best Practices

  • Prefer async/await with try/catch over deeply nested callbacks or long .then/.catch chains — it reads top to bottom and composes with normal control flow.
  • Attach an "error" listener to every EventEmitter and stream you create or consume, before it can possibly emit one.
  • Distinguish operational errors (expected: bad input, network failure) from programmer errors (bugs); only the former should be routinely caught and recovered from.
  • Use custom Error subclasses with a descriptive name and any extra context fields, instead of throwing strings or plain objects.
  • When you catch an error and throw a new, more meaningful one, preserve the original with { cause: err } so nothing is lost from the stack trace.
  • Never leave a catch block empty — log at minimum, even if you intentionally ignore the failure.
  • Treat process.on("uncaughtException") and process.on("unhandledRejection") as last-resort logging and graceful-shutdown hooks, not as normal error handling.
  • Set process.exitCode instead of calling process.exit() immediately, so buffered output and pending I/O can flush before the process ends.
  • Validate external input as early as possible and throw clear, specific operational errors rather than letting a cryptic internal failure surface later.

Practice Exercises

  • Write a function parseJsonSafely(str) that returns the parsed value on success, or throws a custom InvalidJsonError with the original text attached via cause on failure. Call it inside a try/catch and log a friendly message when it fails.
  • Build a small EventEmitter-based Downloader class that emits "progress" and "error" events. Write code that listens to both, and confirm your program does not crash when you manually emit an error.
  • Take the “unhandled rejection” mistake example from this lesson, add a process.on("unhandledRejection", ...) handler that logs the reason, then fix the actual root cause so the handler never fires in the first place.

Summary

  • Synchronous errors use try/catch; callback errors are delivered as the first argument (“error-first”); promise rejections are caught with .catch or await inside try.
  • EventEmitter and streams treat "error" as a special event — with no listener, emitting one crashes the process.
  • Custom Error subclasses with a clear name, extra fields, and cause make failures easier to branch on and debug.
  • process.on("uncaughtException") and process.on("unhandledRejection") are last-resort safety nets for logging and shutdown, not normal control flow.
  • Separate operational errors (expected, recoverable) from programmer errors (bugs) — only the former should be routinely caught and handled.