What you'll learn

By the end of this tutorial you'll have a complete mental model of error handling in Node.js. You'll understand the difference between errors you expect and bugs you don't, how to create custom error classes that carry useful context, how to propagate errors correctly through async code, and how to build a centralized error handler that catches everything in one place. You'll also know how to set up process-level safety nets so an unhandled rejection doesn't silently crash a production server.

Error handling is one of those topics that's easy to put off until something breaks badly in production. This tutorial is the thing you read before that happens.

Who this is for

  • Node.js developers who handle errors with try/catch but aren't sure what to do with them once caught
  • Anyone whose Node process exited because a Promise rejection reached the event loop without a handler
  • Developers building Express APIs who want a single, consistent error response shape

You can skip this if you're already using a structured logging system, have custom error classes in place, and have tested what happens to your process when an async operation rejects without a catch. Come back when any of those three need revisiting.

What is error handling in Node.js?

It's the set of patterns that ensure unexpected conditions are caught, logged, and handled gracefully — without crashing the process when they shouldn't, and without hiding bugs that need to be fixed.

Plain English: when code goes wrong, you have to decide three things: does this error get shown to the user, does it get logged for a developer to investigate, and does the process keep running or stop? Good error handling makes those decisions consistently rather than case-by-case.

Simple idea: every error needs a destination. With Node's default unhandled-rejection behavior, a rejected Promise that reaches the event loop without a handler is raised as an uncaught exception and the process exits. Design each request, job, and background task with a clear error boundary.

Prerequisites

  • Comfortable with JavaScript and async/await
  • A Node.js project to apply these patterns in — even a small Express app works well
  • Basic familiarity with try/catch syntax

Setup from zero

Step 1 — Create the project

mkdir error-lab && cd error-lab
npm init -y
touch app.js errors.js

Step 2 — Install Express for the HTTP examples

npm install express

Step 3 — Create a minimal Express server

// app.js
const express = require("express");
const app = express();
app.use(express.json());

app.get("/", (req, res) => res.json({ status: "ok" }));

app.listen(3000, () => console.log("Server on port 3000"));

Run with node app.js. The error patterns below slot into this base.

> Little tip: before adding any error handling, intentionally trigger an error in your server — throw inside a route, await a rejected Promise — and watch what happens without a handler. The behavior you observe is exactly what your error handling needs to prevent. Seeing the problem firsthand makes the solution much more concrete.

The mental model

Every Node.js error falls into one of two buckets:

Operational errors — expected failure conditions. A database query returns no results. A file doesn't exist. An API call times out. The network is down. These errors are part of normal operation — your code should handle them and continue (or respond gracefully to the caller).

Programmer errors — bugs. A null dereference. A wrong argument type. A typo in a variable name. These indicate a flaw in the code itself. The right response is usually to log the error with full context and crash or restart the process — running on with a programmer error means running in an unknown state.

The practical rule: recover from operational errors, surface programmer errors as loudly as possible. Most production bugs come from trying to recover from programmer errors (suppressing them) or from treating operational errors as fatal (crashing when they were expected).

Key terms

Error class — a JavaScript class that extends Error. Custom subclasses can carry extra context like HTTP status codes, error codes, or user-facing messages.

try/catch — the standard mechanism for catching synchronous and async errors in async functions. If an await-ed Promise rejects and there's no try/catch, the rejection propagates up to the nearest caller that handles it — or becomes an unhandled rejection.

Unhandled rejection — a rejected Promise with no .catch() handler and no surrounding try/catch. In Node.js 15 and later, this crashes the process.

Error middleware — an Express middleware function with four parameters (err, req, res, next). Express routes errors to it when you call next(err) or when a synchronous error is thrown.

process.on('unhandledRejection') — a process-level event fired when a Promise rejects without a handler. A safety net, not a replacement for real error handling.

Stack trace — the list of function calls that led to the error. Essential for debugging — always preserve it when passing errors between functions.

Step-by-step

Build a custom AppError class

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

class NotFoundError extends AppError {
  constructor(resource = "Resource") {
    super(`${resource} not found`, 404);
  }
}

class ValidationError extends AppError {
  constructor(message) {
    super(message, 400);
  }
}

module.exports = { AppError, NotFoundError, ValidationError };

isOperational distinguishes expected failures from bugs. The error middleware will check this flag to decide whether to attempt recovery or treat the error as fatal.

Add a centralized Express error handler

// app.js — add this after all routes, before app.listen

app.use((err, req, res, next) => {
  const statusCode = err.statusCode ?? 500;
  const isOperational = err.isOperational ?? false;

  // Always log the error
  console.error({
    message: err.message,
    statusCode,
    stack: err.stack,
    path: req.path,
    method: req.method,
  });

  const publicMessage = isOperational
    ? err.message
    : "An unexpected error occurred";

  res.status(isOperational ? statusCode : 500).json({ error: publicMessage });
});

This middleware owns the HTTP boundary: it logs the failure, prevents the request from hanging, and hides internal details. It does not restart the process just because it received an unexpected error. Treat those logs as bugs to investigate; process-level fatal errors use the separate safety net below.

Throw and propagate errors from async routes

// Works in Express 5 — async errors propagate automatically
app.get("/users/:id", async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) throw new NotFoundError("User");
  res.json(user);
});

// For Express 4 — wrap async routes to catch rejections
function asyncHandler(fn) {
  return (req, res, next) => fn(req, res, next).catch(next);
}

app.get("/users/:id", asyncHandler(async (req, res) => {
  const user = await User.findById(req.params.id);
  if (!user) throw new NotFoundError("User");
  res.json(user);
}));

Working examples

Propagating errors through async functions

async function getUser(id) {
  const user = await db.collection("users").findOne({ _id: id });
  if (!user) throw new NotFoundError("User");
  return user;
}

async function getUserProfile(id) {
  // No try/catch here — let the error propagate to the route handler
  const user = await getUser(id);
  return { ...user, displayName: user.profile?.name ?? user.email };
}

Don't add try/catch at every layer. Let errors bubble up to the layer that knows how to handle them — usually the route handler or a centralized middleware.

Synchronous throws versus rejected Promises

A synchronous API reports failure by throwing immediately. A Promise-based API reports failure by rejecting, so try/catch sees it only when you await that Promise:

const { readFile, readFileSync } = require("node:fs");
const fs = require("node:fs/promises");

try {
  const config = JSON.parse(readFileSync("config.json", "utf8")); // may throw now
  const profile = await fs.readFile("profile.json", "utf8");      // may reject here
} catch (err) {
  console.error("Could not load startup data:", err);
}

readFile("profile.json", "utf8", (err, data) => {
  if (err) return console.error("Read failed:", err);
  console.log(data);
});

Starting a Promise without awaiting it moves the rejection outside the surrounding try/catch. Return it, await it, or attach a deliberate .catch() at the boundary that owns the work.

Process-level safety nets

const server = app.listen(process.env.PORT || 3000);

function shutdown(signal) {
  console.log(`${signal}: stopping new requests`);

  const forceExit = setTimeout(() => {
    console.error("Graceful shutdown timed out");
    process.exit(1);
  }, 10_000);
  forceExit.unref();

  server.close((err) => {
    clearTimeout(forceExit);
    if (err) {
      console.error("Server close failed:", err);
      process.exitCode = 1;
    }
    // Close database and queue connections here too.
  });
}

process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("SIGINT", () => shutdown("SIGINT"));

process.on("uncaughtExceptionMonitor", (err, origin) => {
  // Observes the fatal error without overriding Node's default exit.
  console.error("Fatal process error:", origin, err);
});

SIGTERM and SIGINT are normal shutdown requests, so the server stops accepting work and lets active requests finish, with a hard timeout as a backstop. An uncaught exception is different: the process may be in an undefined state. uncaughtExceptionMonitor records it but preserves Node's default non-zero exit so an external supervisor can restart a clean process.

> Little tip: use PM2, systemd, a container restart policy, or your hosting platform as the external supervisor. Do not use an uncaughtException listener to resume normal application work after a fatal exception.

Patterns

Log everything, show little — log the full error including the stack trace to your server logs. Show users only a short, non-technical message. Leaking stack traces in API responses is a security risk.

Centralize error formatting — one error handler in Express, one error shape in the response. React and other API consumers can rely on { error: "message" } in every failure response without special-casing per route.

Always return after sending a response — once you call res.json() in an error handler or route, return immediately. Calling res.json() twice on the same response object throws "headers already sent" — one of Express's most common runtime errors.

Common mistakes

Swallowing errors in catch blockscatch (err) {} is the source of some of the hardest-to-debug Node.js bugs. At minimum, log the error. If you're catching it to provide a fallback, make it explicit in a comment why you're doing so.

Using the same error message for every failure — generic messages like "Something went wrong" are useless for debugging. Even internal errors should have a specific log message that identifies what failed and where.

Catching and rethrowing without adding context — if you catch an error just to rethrow it, add something: err.context = { userId: id } or wrap it in a domain-specific error class. The stack trace tells you where it failed; the context tells you what the code was doing.

Troubleshooting

"Cannot set headers after they are sent" — two responses were sent from the same handler. Find every res.json() or res.send() call in the handler chain and add return before each one that's supposed to terminate the function.

Async errors not reaching Express error middleware — you're on Express 4 and haven't wrapped async routes in asyncHandler, or you're await-ing inside a forEach loop where rejections are swallowed. Use for...of for async loops, and wrap routes.

Stack traces cut off or missing — error objects lost their stack trace because someone created new Error(existingError.message) instead of passing the original error through. Always preserve the original error: cause: originalError (ES2022 pattern) or by attaching it directly.

Checklist

  • [ ] A custom AppError class with statusCode and isOperational fields
  • [ ] Domain-specific subclasses for common failures: NotFoundError, ValidationError
  • [ ] Centralized Express error middleware placed after all routes
  • [ ] Async Express routes wrapped in asyncHandler (Express 4) or using native propagation (Express 5)
  • [ ] Every started Promise is returned, awaited, or deliberately caught at its owning boundary
  • [ ] Fatal process errors are monitored without continuing in an unknown state
  • [ ] SIGTERM / SIGINT stop new requests, allow a bounded drain, and close external resources
  • [ ] No catch blocks that swallow errors silently

Practice task

Add error handling to an Express API that has a GET /posts/:id route. Create custom NotFoundError and ValidationError classes. Throw NotFoundError when the post doesn't exist. Throw ValidationError when the ID format is invalid. Add the centralized error middleware. Confirm the error responses have the right HTTP status codes and that the stack trace appears in the server log but not in the API response.

FAQ

Should I use process.exit(1) in production?

Use a non-zero exit for fatal startup failures or when a bounded graceful shutdown cannot finish. For an uncaught exception, preserve Node's default non-zero exit and let an external supervisor restart the process. For normal SIGTERM deploys, stop accepting requests and drain active work before exiting.

When should I throw vs. return an error?

In async functions and Express route handlers, throwing is cleaner — it propagates the error automatically up the call stack. Returning errors (as the second value of a tuple, or as a typed Result object) makes sense when the caller genuinely needs to choose between success and failure paths at the call site.

How do I handle errors in background jobs or queues?

Use the same custom error classes, but catch errors at the job level and mark the job as failed. Log the full error. Whether to retry depends on whether the error is operational (network timeout — retry makes sense) or a programmer error (invalid data shape — retrying will fail the same way).

What to learn next

Once error handling is solid: Node.js security basics (which builds directly on validation and error boundary patterns), structured logging with a library like pino for JSON logs that observability tools can parse, and testing async error paths with node:test.

Takeaways

Every Node.js error is either an expected failure (operational) or a bug (programmer error). Operational errors get handled gracefully; programmer errors get logged loudly and trigger a restart. Custom error classes carry status codes and context. A centralized error middleware handles the formatting. Process-level safety nets are last-resort catches — not a substitute for structured handling throughout the code.

If you remember only one thing: never write a catch block that swallows the error silently. If you catch an exception and do nothing with it, you've hidden a problem that will surface later at a completely unrelated point in the code — and that debugging session will be very unpleasant.