Skip to content
Node.js best practices 5 min read

Error Handling Best Practices

Error handling is where Node.js applications quietly succeed or spectacularly fall over. The runtime is unforgiving about unhandled rejections and uncaught exceptions, and async code makes it easy to swallow failures by accident. The strongest codebases treat error handling as a deliberate design concern: they distinguish errors they expected from bugs they did not, fail fast on the latter, route everything through one place, and never throw away the context that makes an error diagnosable. This page lays out those practices with runnable examples.

Operational vs programmer errors

The single most useful distinction in error handling is between operational and programmer errors. Operational errors are expected runtime conditions in a correct program: a network timeout, a 404 from an upstream API, invalid user input, a full disk. They are part of normal operation and should be handled gracefully. Programmer errors are bugs — calling a method on undefined, passing the wrong type, a typo in a property name. You cannot meaningfully recover from a bug at runtime; the safe response is to crash and let a supervisor restart a clean process.

TypeExamplesRecoverable?Right response
OperationalTimeout, 404, bad input, DB connection lostYesCatch, retry, or return a clean error to the caller
Programmerundefined is not a function, wrong argument typeNoLog with full context, then fail fast and restart

The danger is treating them the same. Catching every error and continuing hides bugs; crashing on every error makes you brittle. Tagging errors as operational lets you decide correctly at the boundary.

// src/errors/AppError.js
export class AppError extends Error {
  constructor(message, { statusCode = 500, isOperational = true, cause } = {}) {
    super(message, { cause });
    this.name = this.constructor.name;
    this.statusCode = statusCode;
    this.isOperational = isOperational;
    Error.captureStackTrace(this, this.constructor);
  }
}

Custom error types

Subclassing Error gives you semantic types you can branch on with instanceof, instead of string-matching messages. Each type carries the metadata a caller needs — an HTTP status, a machine-readable code, the offending field. Pass the original error via the standard cause option so the underlying failure is never lost.

// src/errors/index.js
import { AppError } from "./AppError.js";

export class NotFoundError extends AppError {
  constructor(resource) {
    super(`${resource} not found`, { statusCode: 404 });
  }
}

export class ValidationError extends AppError {
  constructor(message, field) {
    super(message, { statusCode: 400 });
    this.field = field;
  }
}
import { NotFoundError } from "./errors/index.js";

async function getUser(id, repo) {
  const user = await repo.findById(id);
  if (!user) {
    throw new NotFoundError("User");
  }
  return user;
}

Prefer the built-in cause option (new Error(msg, { cause: err }), supported since Node 16) over rolling your own originalError property. Stack-aware tools and util.inspect understand it natively.

Fail fast on the unknown

When an error is not a known operational error, do not soldier on with corrupted state — surface it loudly. At the process level this means installing last-resort handlers that log and then exit, letting your process manager (PM2, systemd, Kubernetes) start a fresh instance. Recovering in place after an uncaught exception is unsafe because the runtime may be in an inconsistent state.

// src/process-guards.js
import { logger } from "./logger.js";

export function installProcessGuards(server) {
  process.on("unhandledRejection", (reason) => {
    // Convert to an uncaught exception so it gets the same treatment.
    throw reason;
  });

  process.on("uncaughtException", (err) => {
    logger.fatal({ err }, "uncaught exception — shutting down");
    server.close(() => process.exit(1));
    // Force-exit if graceful close hangs.
    setTimeout(() => process.exit(1), 10_000).unref();
  });
}

Output:

{"level":"fatal","err":{"type":"TypeError","message":"Cannot read properties of undefined (reading 'id')","stack":"TypeError: ..."},"msg":"uncaught exception — shutting down"}

Centralize handling

Error handling logic — deciding the status code, formatting the response, logging — should live in one module, not be copy-pasted across every route. In Express, that is a single error-handling middleware registered last. Controllers do nothing but catch and forward with next(err); the central handler decides what the client sees and whether the error is worth alarming on.

// src/middleware/error-handler.js
import { AppError } from "../errors/AppError.js";
import { logger } from "../logger.js";

export function errorHandler(err, req, res, next) {
  const isOperational = err instanceof AppError && err.isOperational;
  const statusCode = err.statusCode ?? 500;

  logger.error(
    { err, requestId: req.id, path: req.path, method: req.method },
    "request failed",
  );

  res.status(statusCode).json({
    error: {
      message: isOperational ? err.message : "Internal Server Error",
      code: err.name,
    },
  });

  if (!isOperational) {
    next(err); // let the process guard decide whether to crash
  }
}

Note that operational errors expose their real message, while unknown errors return a generic string — leaking stack traces or internal messages to clients is both a usability and a security problem.

Always log with context

An error message alone is rarely enough to debug a production incident. Log the full error object (so the stack and cause chain are captured) alongside structured context: a request ID, the user, the operation, relevant inputs. Use a structured logger like Pino so logs are queryable JSON rather than opaque strings, and never console.log(err.message) — that discards the stack.

import { ValidationError } from "./errors/index.js";
import { logger } from "./logger.js";

async function chargeCard(order) {
  try {
    return await paymentGateway.charge(order.amount, order.token);
  } catch (cause) {
    logger.warn(
      { err: cause, orderId: order.id, amount: order.amount },
      "charge failed",
    );
    throw new ValidationError("Payment declined", "token");
  }
}

Best practices

  • Tag every thrown error as operational or not (isOperational), and branch on that flag at the boundary rather than guessing from the message.
  • Define custom Error subclasses with status codes and a machine-readable name; use instanceof, not string matching.
  • Preserve causes with the native { cause } option so the original failure survives every rethrow.
  • Crash on unknown errors and let a supervisor restart you; only retry or recover from errors you explicitly expected.
  • Funnel all errors through one centralized handler so status, formatting, and logging stay consistent.
  • Log the whole error object with structured context (request ID, user, operation) using a JSON logger; never log only err.message.
  • Return generic messages to clients for non-operational errors to avoid leaking internals.
Last updated June 14, 2026
Was this helpful?