Error Handling

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Express 5.3 (npm express 5.3.0, published 2026-10-09) on Node.js 18 or later, the minimum for Express 5, and it states where Express 4 (latest 4.22.3) behaves differently. Everything was checked against the official documentation and the package repositories on 2026-10-29. Error handling is the part of Express that most tutorials get wrong, because the rules changed in Express 5: a rejected promise now reaches the error handlers by itself, which makes a whole generation of wrappers and packages unnecessary. The page follows an error from the place where it is raised to the response the client finally sees, and builds a complete error layer for the Bookshelf API that grows across this section. The examples use ESM and the node: prefix for built-in modules, and contain placeholders only, never real secrets. The background on how layers are chained is in Middleware Fundamentals.

How an error travels

Express has exactly one error path. A layer enters it in one of three ways, and from that moment every normal layer is skipped until a function declared with four parameters is found:

  1. The function throws synchronously. Express wraps every layer call in a try/catch and turns the exception into next(err).

  2. The function calls next(err) with any truthy value, except the two special strings 'route' and 'router'.

  3. The function returns a promise that rejects (Express 5 only). The router attaches a rejection handler to the returned promise and calls next(reason).

The first error-handling middleware registered after the layer that raised the error receives it. If that handler calls next(err), the error moves on to the next error handler, and so on. When the list is exhausted, the error reaches the default handler, the finalhandler package, which writes a plain HTML response (or closes the connection when the response had already started).

flowchart TD A["Layer or route handler runs"] --> B{"How did it fail?"} B -->|"throws synchronously"| C["Router catches the exception"] B -->|"calls next with an error"| D["next(err)"] B -->|"returns a rejected promise (Express 5)"| E["Router calls next(reason)"] C --> D E --> D D --> F["Skip every normal layer"] F --> G{"Is there an error handler with four parameters later in the stack?"} G -->|"yes"| H["Custom error handler: sends the response, or calls next(err) to pass it on"] H -->|"calls next(err)"| G G -->|"no"| I["finalhandler: default HTML response, stack hidden in production"] I -->|"headers already sent"| J["Connection is closed"]

Because the path is a straight line, three rules explain nearly every error-handling bug: the handler must be registered after the code that fails, it must declare four parameters, and it must not run after the response has started unless it checks res.headersSent.

Synchronous errors

Code that runs inside a layer’s function, before it returns, needs no special treatment in either major version. A throw is caught by the router:

// sync-errors.mjs
import express from 'express';

const app = express();

app.get('/boom', (req, res) => {
  throw new Error('Something broke'); // caught by Express, becomes next(err)
});

app.get('/parse', (req, res) => {
  // JSON.parse throws a SyntaxError on invalid input; Express catches it too.
  const value = JSON.parse(req.query.json ?? '{}');
  res.json(value);
});

app.use((err, req, res, next) => {
  if (res.headersSent) {
    return next(err);
  }
  res.status(500).type('text/plain').send(`Handled: ${err.message}`);
});

app.listen(3000, (error) => {
  if (error) {
    throw error;
  }
});

The same rule covers app.use() and router.use() layers, route handlers and router.param() callbacks. What the router cannot catch is a throw that happens after the function has returned: inside a timer callback, an event listener or a callback passed to a callback-style library. Those are uncaught exceptions, and Node.js terminates the process by default. The next two sections show how to bring them back onto the Express path.

Promise forwarding in Express 5

In Express 4 the return value of a layer was ignored. An async function that threw, or awaited a rejected promise, produced an unhandled rejection: the request hung, Node.js printed a warning, and from Node.js 15 on the process terminated. The popular workarounds were a try/catch calling next(err) in every handler, a small higher-order wrapper around each handler, or a package that patched the framework.

Express 5 handles it at the source. When a layer’s function returns a promise (an async function, or any thenable), the router attaches a rejection handler and forwards the rejection reason to next(err). A throw inside an async function is a rejection, so it takes the same path. When the rejection reason is falsy (Promise.reject() without an argument), the router substitutes an Error so that the error path still runs. This works for route handlers, app.use() and router.use() layers, and router.param() callbacks.

// promise-forwarding.mjs
import { setTimeout as sleep } from 'node:timers/promises';
import express from 'express';

// A stand-in for a database call.
const repository = {
  async findById(id) {
    await sleep(10);
    if (id === 'down') {
      throw new Error('database connection lost');
    }
    return id === '1' ? { id: '1', title: 'Dune' } : null;
  },
};

const app = express();

// No try/catch, no wrapper: a rejection or a throw reaches the error handler.
app.get('/books/:id', async (req, res) => {
  const book = await repository.findById(req.params.id);
  if (!book) {
    const error = new Error(`Book ${req.params.id} not found`);
    error.status = 404;
    throw error;
  }
  res.json(book);
});

app.use((err, req, res, next) => {
  if (res.headersSent) {
    return next(err);
  }
  const status = err.status ?? 500;
  res.status(status).json({ status, message: status === 500 ? 'Internal Server Error' : err.message });
});

app.listen(3000, (error) => {
  if (error) {
    throw error;
  }
});

The limits of the mechanism are exactly the cases where no promise is being watched:

  • Only the promise returned by the layer is watched. A rejection created and left behind (a call that is not awaited and not returned) is an unhandled rejection, as in any Node.js program.

  • A callback that runs later, such as an event listener or a setTimeout callback, is outside the returned promise. Use the promise versions of the API (node:fs/promises, node:timers/promises, util.promisify()), or call next(err) yourself.

  • Resolving is not responding. An async layer that finishes without sending a response and without calling next() leaves the request hanging.

  • Express 4 ignores the returned promise completely. Code that must run on both major lines needs a wrapper until it is upgraded; see Where the books are outdated and Migrating & What Changed.

Why express-async-errors and handler wrappers are no longer needed

Two idioms existed only to compensate for Express 4 ignoring promises:

  • The express-async-errors package, which is imported once and patches Express internals so that a rejected promise is passed to next. Its own documentation targets Express 4, and the patch is useless on Express 5, whose router does the same thing natively. Patching a framework’s private layer implementation is also a maintenance risk: an Express update can change those internals without any public-API notice.

  • A wrapper such as const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next) applied to every route. On Express 5 it is redundant: the wrapped function already returns a promise that the router watches, and the wrapper only adds an extra frame to stack traces and a function to remember.

On Express 5, delete the package and the wrapper. Keep one of them only in a codebase that still runs on Express 4, and remove it when the dependency moves to 5. A wrapper is still reasonable in one case on Express 5: a library function that mixes callbacks and promises and must be adapted explicitly. Even then, util.promisify() is clearer.

One behaviour is worth remembering when removing them: Express 5 forwards the rejection reason as it is, with no change to the error object, so existing error handlers keep working. The migration guide lists the change among the Express 5 behaviour changes, so a handler that returned a rejected promise on purpose and ignored it would now trigger the error path.

Callback APIs and next(err)

Callback-style code remains common (older drivers, streams, event emitters). The rule in Express is unchanged: report the failure with next(err), and then stop, usually with return.

// callback-errors.mjs
import { readFile } from 'node:fs';
import { createReadStream } from 'node:fs';
import { fileURLToPath } from 'node:url';
import express from 'express';

const app = express();
const dataFile = fileURLToPath(new URL('./books.json', import.meta.url));

// A callback API: the error is reported through next, and we return immediately.
app.get('/books', (req, res, next) => {
  readFile(dataFile, 'utf8', (error, text) => {
    if (error) {
      return next(error); // without the return, the next line would run and respond twice
    }
    try {
      res.type('application/json').send(text);
    } catch (parseError) {
      next(parseError);
    }
  });
});

// A stream: errors are events, so they must be forwarded explicitly.
app.get('/books/raw', (req, res, next) => {
  const stream = createReadStream(dataFile);
  stream.on('error', next);
  stream.pipe(res);
});

app.use((err, req, res, next) => {
  if (res.headersSent) {
    return next(err);
  }
  res.status(err.code === 'ENOENT' ? 404 : 500).json({ message: 'Could not read the catalogue' });
});

app.listen(3000, (error) => {
  if (error) {
    throw error;
  }
});

Notes on the example. An event emitter without an error listener throws when it emits error, which becomes an uncaught exception, so a stream or socket created inside a layer needs .on('error', next). stream.pipe(res) does not forward source errors to the destination and does not close it, which is why stream.pipeline() from node:stream is preferable when the response must be finished cleanly. And once the pipe has started, the headers are already sent, so the error handler sees res.headersSent set to true and must delegate instead of replying (see ERR_HTTP_HEADERS_SENT and res.headersSent).

Writing error-handling middleware

An error handler is an ordinary middleware function with four declared parameters, (err, req, res, next). Express decides whether a function is an error handler by its length, so the fourth parameter must be declared even when it is never used. The consequences are practical:

  • Default values or a rest parameter change length and silently turn the function back into a normal layer. Keep the signature plain.

  • A handler registered before the failing layer never sees the error. Register error handlers after every route and router.

  • Several error handlers can be chained. Each one either sends a response or calls next(err) to pass the error on. Calling next() with no argument resumes the normal stack, which is rarely what is wanted.

  • The handler can be mounted on a path or on a router (router.use((err, req, res, next) => ...)), in which case it handles only errors raised from that router. Use that to give an /api area JSON errors while an HTML area keeps its own pages.

A common way to organise the chain is one small handler per concern, in order of increasing generality: log the error, translate known errors, answer.

// handler-chain.mjs
import express from 'express';

const app = express();

app.get('/fail', () => {
  throw new Error('Something broke');
});

// 1. Log, then pass the error on.
function logErrors(err, req, res, next) {
  console.error(`${req.method} ${req.originalUrl}`, err);
  next(err);
}

// 2. Handle only the requests that expect JSON; delegate the rest.
function jsonErrorHandler(err, req, res, next) {
  if (req.accepts(['json', 'html']) !== 'json') {
    return next(err);
  }
  res.status(err.status ?? 500).json({ message: err.expose ? err.message : 'Internal Server Error' });
}

// 3. Last resort for everything else.
function fallbackErrorHandler(err, req, res, next) {
  if (res.headersSent) {
    return next(err); // finalhandler closes the connection
  }
  res.status(err.status ?? 500).type('text/plain').send('Something went wrong');
}

app.use(logErrors, jsonErrorHandler, fallbackErrorHandler);

app.listen(3000, (error) => {
  if (error) {
    throw error;
  }
});

Inside the handler, an error can be anything that was thrown or passed to next, including strings, plain objects and null-prototype values. Code that reads err.message or err.status on an unknown value should be defensive, and a central place to normalise it (see Mapping library errors) keeps the handlers short.

The default handler: finalhandler

If no error handler takes the error, Express hands it to finalhandler, the pillarjs package Express 5 depends on (2.x). It is the same function that answers an unmatched request with Cannot GET /path. For an error it behaves as follows:

Aspect Behaviour

Status code

Uses err.status or err.statusCode when it is an integer between 400 and 599. Otherwise it keeps a 4xx or 5xx status already set on the response, and falls back to 500.

Headers

Copies the object in err.headers onto the response, which is how errors created with http-errors can carry headers such as WWW-Authenticate or Retry-After.

Body

A small HTML page. Outside production the body is the error’s stack trace (HTML-escaped); when NODE_ENV is production, only the standard reason phrase for the status is shown.

Security headers

Sets Content-Security-Policy: default-src 'none', X-Content-Type-Options: nosniff and a Content-Type of text/html, so an error page cannot execute scripts.

Response already started

If headers were already sent, it cannot change the response and destroys the socket instead, so the client sees an interrupted response rather than a truncated one that looks complete.

No error, no match

Answers 404 with Cannot METHOD /path.

Three consequences follow. First, NODE_ENV=production must be set in production, because that is the switch that keeps stack traces out of client responses (the test is an exact comparison with the string production, so values such as prod do not count). Second, the default page is HTML only: a JSON client that hits it gets markup. A custom handler that answers in the format the client expects is part of any API. Third, the default handler is a last resort, not a logger: Express hands it a callback that writes the stack to console.error (in every environment except test), with no request method, URL or structure, so a handler or structured logger should record application errors itself.

The errorhandler middleware for development

errorhandler is an official Express middleware (npm errorhandler, version 1.5.2 on 2026-10-29) that sends the full error, including the stack trace, to the client and negotiates the format from the Accept header: HTML, JSON or plain text. It exists for local development, where seeing the stack in the browser or in curl output is convenient. It discloses internal information, so it must never be enabled in production.

// errorhandler.mjs
import errorHandler from 'errorhandler';
import express from 'express';

const app = express();

app.get('/boom', () => {
  throw new Error('Something broke');
});

// Register it last, and only outside production.
if (process.env.NODE_ENV === 'development') {
  app.use(errorHandler({ log: true }));
} else {
  app.use((err, req, res, next) => {
    if (res.headersSent) {
      return next(err);
    }
    console.error(err);
    res.status(err.status ?? 500).json({ title: 'Internal Server Error', status: err.status ?? 500 });
  });
}

app.listen(3000, (error) => {
  if (error) {
    throw error;
  }
});

Install it with npm install errorhandler and run NODE_ENV=development node errorhandler.mjs. The log option accepts true (log to console.error, the default), false, or a function (err, str, req, res) to log through your own logger; the function form lets the same module write structured logs. Because errorhandler checks nothing about the environment on its own, the if above is the safeguard. A handler that branches on NODE_ENV inside one function is an alternative, but two separate registrations make the production path impossible to reach by accident.

A JSON 404 handler

An unmatched request is not an error to Express: the router runs out of layers and finalhandler answers 404. To control the response, register one ordinary layer after every route:

// not-found.mjs
import express from 'express';

const app = express();

const router = express.Router();
router.get('/books', (req, res) => {
  res.json([]);
});
app.use('/api/v1', router);

// After every route and router: nothing matched.
app.use((req, res) => {
  res.status(404).type('application/problem+json').json({
    type: 'about:blank',
    title: 'Not Found',
    status: 404,
    detail: `No resource at ${req.method} ${req.path}`,
    instance: req.originalUrl,
  });
});

app.listen(3000, (error) => {
  if (error) {
    throw error;
  }
});

Two details are worth knowing. The 404 layer is a normal layer, so it must be registered after the routes but before the error handlers; otherwise a route that wrongly falls through would reach it by accident, and a handler placed before it could not intercept its output. And HEAD requests for a missing resource arrive here too, where res.json() sends the headers without a body, as the protocol requires. A different design makes the 404 layer create an error with status 404 and calls next(err), so that one error handler formats every failure. The next section uses that design.

Problem details (RFC 9457)

RFC 9457, Problem Details for HTTP APIs (July 2023), obsoletes RFC 7807 and defines a machine-readable format for error responses, so that every API does not have to invent its own. The media type is application/problem+json (and application/problem+xml). The object may contain these members, all optional:

Member Meaning

type

A URI reference that identifies the kind of problem and, when dereferenced, should lead to human-readable documentation. When absent, it is about:blank, which means the problem has no more specific meaning than the HTTP status itself.

status

The HTTP status code, repeated in the body for consumers that receive only the body. It must agree with the real status.

title

A short human-readable summary of the problem type. It should not change between occurrences of the same type.

detail

A human-readable explanation specific to this occurrence. Clients must not parse it.

instance

A URI reference that identifies this occurrence (a request path or an error ID that can be quoted in a support ticket).

Any other member is an extension member. The RFC reserves no name, but clients must ignore extensions they do not understand. The usual extension for validation failures is an errors array that points at each invalid field. The RFC also warns against leaking internals: detail and extensions are for the client and must not contain stack traces, SQL or file paths.

The following module implements the design: a small ProblemError class, helpers for common cases, a 404 layer that raises an error, and a single error handler that renders everything as problem details.

// problem-details.mjs
import { randomUUID } from 'node:crypto';
import express from 'express';

const PROBLEM_TYPE_BASE = 'https://bookshelf.example.com/problems';

/** An error that the API is willing to describe to its clients. */
export class ProblemError extends Error {
  constructor(status, title, { type = 'about:blank', detail, errors, headers, cause } = {}) {
    super(detail ?? title, { cause });
    this.name = 'ProblemError';
    this.status = status;
    this.title = title;
    this.type = type;
    this.detail = detail;
    this.errors = errors;
    this.headers = headers;
    this.expose = status < 500;
  }
}

export const notFound = (detail) => new ProblemError(404, 'Not Found', { detail });
export const validationFailed = (errors) =>
  new ProblemError(422, 'Validation failed', {
    type: `${PROBLEM_TYPE_BASE}/validation`,
    detail: 'One or more fields are invalid.',
    errors,
  });

/** Normalises anything that was thrown into a ProblemError. */
function toProblem(err) {
  if (err instanceof ProblemError) {
    return err;
  }
  // Errors raised by Express and its body parser carry a status and a type.
  if (err && typeof err === 'object' && err.expose === true && Number.isInteger(err.status ?? err.statusCode)) {
    const status = err.status ?? err.statusCode;
    return new ProblemError(status, err.name ?? 'Error', { detail: err.message, headers: err.headers, cause: err });
  }
  return new ProblemError(500, 'Internal Server Error', { cause: err });
}

const app = express();
app.use(express.json({ limit: '100kb' }));

const books = new Map([['1', { id: '1', title: 'Dune' }]]);

app.get('/api/v1/books/:id', (req, res) => {
  const book = books.get(req.params.id);
  if (!book) {
    throw notFound(`No book with id ${req.params.id}`);
  }
  res.json(book);
});

app.post('/api/v1/books', (req, res) => {
  const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
  if (!title) {
    throw validationFailed([{ pointer: '/title', message: 'title is required' }]);
  }
  const book = { id: String(books.size + 1), title };
  books.set(book.id, book);
  res.status(201).location(`/api/v1/books/${book.id}`).json(book);
});

// Unmatched requests become a 404 problem, handled by the same error handler.
app.use((req) => {
  throw notFound(`No resource at ${req.method} ${req.path}`);
});

app.use((err, req, res, next) => {
  if (res.headersSent) {
    return next(err);
  }
  const problem = toProblem(err);
  const instance = `urn:uuid:${randomUUID()}`;
  if (problem.status >= 500) {
    // The only place where internals are written down: the log, with the same ID.
    console.error(instance, err);
  }
  if (problem.headers) {
    res.set(problem.headers);
  }
  res
    .status(problem.status)
    .type('application/problem+json')
    .json({
      type: problem.type,
      title: problem.title,
      status: problem.status,
      // Only describe what the API decided to expose; never leak a 5xx message.
      ...(problem.expose && problem.detail ? { detail: problem.detail } : {}),
      instance,
      ...(problem.errors ? { errors: problem.errors } : {}),
    });
});

app.listen(3000, (error) => {
  if (error) {
    throw error;
  }
});

A client then sees:

POST /api/v1/books HTTP/1.1
Content-Type: application/json

{"title": ""}

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json; charset=utf-8

{
  "type": "https://bookshelf.example.com/problems/validation",
  "title": "Validation failed",
  "status": 422,
  "detail": "One or more fields are invalid.",
  "instance": "urn:uuid:3f2b8c7e-5a31-4d0e-9a4f-6b1f3f7f2c10",
  "errors": [{ "pointer": "/title", "message": "title is required" }]
}

The design choices that matter:

  • The instance member is the correlation ID: it appears in the response and in the log line, so a support request that quotes it finds the stack trace.

  • expose follows the convention of http-errors: it is true only for 4xx statuses, so the message of a 5xx error never reaches the client unless a developer opts in.

  • Throwing from the 404 layer works in a synchronous layer because the router catches it; an async layer would also work in Express 5.

  • A malformed JSON body makes express.json() raise an error of type entity.parse.failed with status 400, which the normalisation maps to a problem with that status. An oversized body gives 413 (entity.too.large).

http-errors and the error properties Express reads

Express itself reads only a few properties of an error object, and a small ecosystem has grown around them:

Property Used by

status or statusCode

finalhandler and most custom handlers to choose the response status. Values outside 400 to 599 are ignored by finalhandler.

headers

finalhandler copies them onto the response (for example Retry-After on a rate limit).

expose

A convention from http-errors and body-parser: true means the message is safe to show to clients. It is true by default for 4xx and false for 5xx. Express does not read it, but your handler should.

type, code

Used by libraries to classify an error (entity.parse.failed, ENOENT, a database code). Match on these, not on message.

The http-errors package (maintained under the jshttp organisation, and a dependency of Express and its body parser) creates errors with those properties already set, which avoids hand-written classes for the common cases:

// http-errors-example.mjs
import express from 'express';
import createError, { isHttpError } from 'http-errors';

const app = express();

app.get('/books/:id', (req, res) => {
  if (!/^\d+$/.test(req.params.id)) {
    throw createError(400, 'The book id must be a number'); // status 400, expose true
  }
  throw createError(404, `No book with id ${req.params.id}`); // new HttpError subclass: NotFoundError
});

app.get('/admin', () => {
  throw createError(401, 'Authentication required', { headers: { 'WWW-Authenticate': 'Bearer' } });
});

app.use((err, req, res, next) => {
  if (res.headersSent) {
    return next(err);
  }
  if (isHttpError(err)) {
    return res
      .status(err.status)
      .set(err.headers ?? {})
      .json({ status: err.status, message: err.expose ? err.message : 'Internal Server Error' });
  }
  console.error(err);
  res.status(500).json({ status: 500, message: 'Internal Server Error' });
});

app.listen(3000, (error) => {
  if (error) {
    throw error;
  }
});

Install with npm install http-errors. createError() accepts a status, a message, an existing error to augment, and a properties object, in any order. Named constructors exist too (createError.NotFound()).

Mapping library errors

Libraries signal failure in their own way. A central translation function, called from the error handler, keeps routes free of catch blocks and gives the client one format. Match on stable properties (name, code, type) and never on message text.

Source Typical signature Mapping

express.json() / body-parser

err.type === 'entity.parse.failed', entity.too.large, charset.unsupported, encoding.unsupported; err.status set

Keep the status (400, 413, 415). The body-parser messages are safe to show (expose is true).

Your validation library (Zod, Joi, express-validator)

A ZodError, a Joi ValidationError, or an array of results

422 (or 400) with an errors list built from the library’s issues. See Validation & Input Handling.

express-jwt

err.name === 'UnauthorizedError', err.status === 401, err.code such as invalid_token or credentials_required

401 with a WWW-Authenticate: Bearer header. Without a JSON handler, the client gets the default HTML page.

PostgreSQL through pg

err.code === '23505' (unique violation), '23503' (foreign key), '23502' (not null), '40001' (serialization)

409 for a duplicate or foreign-key conflict; 500 for the rest, with the details logged only.

MongoDB through Mongoose

ValidationError, CastError, err.code === 11000 (duplicate key)

422, 400 and 409 respectively.

node:sqlite

err.code === 'ERR_SQLITE_ERROR' with a numeric errcode

Constraint violations become 409; other codes are 500.

Network and file system errors

ECONNREFUSED, ETIMEDOUT, ENOENT

503 for a downstream dependency being unavailable, 404 only when the missing file is the requested resource.

The translation belongs in one function:

// map-errors.mjs
import { ProblemError } from './problem-details.mjs';

/** Converts library errors into ProblemError; returns the input unchanged if it is not recognised. */
export function mapKnownErrors(err) {
  if (err?.name === 'UnauthorizedError') {
    return new ProblemError(401, 'Unauthorized', {
      detail: 'A valid access token is required.',
      headers: { 'WWW-Authenticate': 'Bearer' },
      cause: err,
    });
  }
  if (err?.code === '23505') {
    return new ProblemError(409, 'Conflict', { detail: 'The resource already exists.', cause: err });
  }
  if (err?.type === 'entity.parse.failed') {
    return new ProblemError(400, 'Bad Request', { detail: 'The request body is not valid JSON.', cause: err });
  }
  if (err?.code === 'ECONNREFUSED') {
    return new ProblemError(503, 'Service Unavailable', { headers: { 'Retry-After': '30' }, cause: err });
  }
  return err;
}

Call mapKnownErrors(err) as the first line of the error handler in the previous section. The package name express-jwt and its UnauthorizedError are examples of the pattern; the same approach applies to any middleware that signals failure through next(err).

ERR_HTTP_HEADERS_SENT and res.headersSent

Error [ERR_HTTP_HEADERS_SENT]: Cannot set headers after they are sent to the client is the most common error message in Express applications. It is thrown by Node.js (not by Express) when code tries to set or send a header on a response whose headers have already gone out. There are two typical situations.

The first is responding twice in the handler. The usual cause is a missing return:

// headers-sent.mjs
import express from 'express';

const app = express();

app.get('/wrong', (req, res) => {
  if (!req.query.id) {
    res.status(400).json({ message: 'id is required' }); // response sent...
  }
  res.json({ ok: true }); // ...and a second response: ERR_HTTP_HEADERS_SENT
});

app.get('/right', (req, res) => {
  if (!req.query.id) {
    return res.status(400).json({ message: 'id is required' });
  }
  res.json({ ok: true });
});

app.listen(3000, (error) => {
  if (error) {
    throw error;
  }
});

The second is an error that arrives after the response has started: a streaming response that fails halfway, a handler that calls next() and keeps working, or a late rejection. The error handler then cannot send a new status or body. Writing to the response would throw ERR_HTTP_HEADERS_SENT again from inside the handler and hide the original failure. The documented remedy is to check res.headersSent and delegate:

app.use((err, req, res, next) => {
  if (res.headersSent) {
    return next(err); // finalhandler destroys the socket; the client sees an aborted response
  }
  // ... build and send the error response
});

Delegating is correct for two reasons. The client must learn that the response was incomplete, and closing the connection is the only reliable signal once a status line has been written. The failure is also still logged, because finalhandler was given the error. Do not attempt res.write() of an error body in this state: for a streamed JSON or CSV response it would produce invalid data that looks successful.

The following conditions also cause the error, and are worth ruling out when it appears:

  • next() after sending, inside the same handler;

  • an async function that responds and then continues to a second res.send();

  • two middleware that both answer, for example a CORS layer replying to preflights and a route also replying;

  • a callback that fires twice (an event listener registered on every request, so earlier requests keep responding);

  • res.redirect() followed by further writing.

A defensive test during debugging is if (res.headersSent) console.trace('already sent') before each response; with DEBUG=express:* the log shows which layer ran last. See Debugging & Logging.

Errors outside the request cycle

Errors raised outside any request (an unhandled rejection, an uncaught exception from a callback) never reach Express error handlers. Node.js terminates the process on an unhandled rejection by default, and it terminates on an uncaught exception unless a handler is installed. The safe pattern is to log and exit, because the process can be in an inconsistent state:

// process-level.mjs
const server = app.listen(3000);

function fatal(kind, error) {
  console.error(kind, error);
  server.close(() => process.exit(1));
  setTimeout(() => process.exit(1), 10_000).unref(); // do not wait forever for open connections
}

process.on('uncaughtException', (error) => fatal('uncaughtException', error));
process.on('unhandledRejection', (reason) => fatal('unhandledRejection', reason));

Resuming after an uncaught exception, which older tutorials suggest, is unsafe. Let a supervisor (systemd, Kubernetes, a process manager) restart the process; graceful shutdown details are in Health Checks & Graceful Shutdown.

A complete error layer for Bookshelf

The pieces above compose into one module that an application imports and mounts last. It handles 404, translates library errors, renders problem details, logs 5xx with a correlation ID, and delegates when headers were sent.

// error-layer.mjs
import { randomUUID } from 'node:crypto';
import { STATUS_CODES } from 'node:http';
import createError, { isHttpError } from 'http-errors';

/** Registers the 404 layer and the error handler. Call it after every route. */
export function installErrorLayer(app, { logger = console, isProduction = process.env.NODE_ENV === 'production' } = {}) {
  app.use((req, res, next) => {
    next(createError(404, `No resource at ${req.method} ${req.path}`));
  });

  app.use((err, req, res, next) => {
    if (res.headersSent) {
      return next(err);
    }

    const status = isHttpError(err) ? err.status : Number.isInteger(err?.status) && err.status >= 400 && err.status < 600 ? err.status : 500;
    const instance = `urn:uuid:${randomUUID()}`;

    if (status >= 500) {
      logger.error({ instance, err });
    }

    if (isHttpError(err) && err.headers) {
      res.set(err.headers);
    }

    const exposed = status < 500 && (err.expose ?? true);
    res
      .status(status)
      .type('application/problem+json')
      .json({
        type: 'about:blank',
        title: STATUS_CODES[status] ?? 'Error',
        status,
        detail: exposed ? err.message : undefined,
        instance,
        // Development convenience only; never in production responses.
        stack: isProduction ? undefined : err?.stack,
      });
  });
}

JSON.stringify omits properties whose value is undefined, so detail and stack disappear from the body when they are not set. Use it from the server module:

// server.mjs
import express from 'express';
import { installErrorLayer } from './error-layer.mjs';

const app = express();
app.use(express.json());

app.get('/api/v1/books', async (req, res) => {
  res.json([{ id: '1', title: 'Dune' }]);
});

installErrorLayer(app); // always last

app.listen(3000, (error) => {
  if (error) {
    throw error;
  }
});

A test of the layer with supertest and node:test belongs in the testing page; see Testing.

The official error and error-pages examples

The expressjs/express repository ships two relevant examples. The error example shows the minimal pattern: routes that throw or call next(err), and a single four-argument handler, registered after the routes, that logs the stack and answers 500. The error-pages example uses a template engine: its final 404 layer calls res.format() directly to answer an HTML page, JSON or text, and its error handler sets the status from err.status (default 500) but always renders the 500 template. The Bookshelf layer above is the same structure with problem details in place of HTML.

// content-negotiation.mjs
import express from 'express';

const app = express();

app.get('/books/:id', () => {
  const error = new Error('Book not found');
  error.status = 404;
  throw error;
});

app.use((err, req, res, next) => {
  if (res.headersSent) {
    return next(err);
  }
  const status = err.status ?? 500;
  res.status(status);
  res.format({
    html: () => res.send(`<h1>${status}</h1><p>${status === 500 ? 'Server error' : 'Not found'}</p>`),
    json: () => res.json({ status, message: status === 500 ? 'Server error' : err.message }),
    default: () => res.type('text/plain').send(`${status}`),
  });
});

app.listen(3000, (error) => {
  if (error) {
    throw error;
  }
});

The HTML in the example contains no user-supplied data. If an error page includes values from the request, escape them or use a template engine that does; rendering a stack trace or message through an unescaped tag is a cross-site scripting hole (see Template Engines & Views).

What changed recently

Version Change relevant to error handling

Express 5.0.0 (2024-09)

Rejected promises returned by route handlers and middleware are forwarded to next(err). app.listen() passes startup errors (such as EADDRINUSE) to its callback instead of throwing from an event. finalhandler 2.x is the default handler. res.status() accepts only integers from 100 to 999 and throws a RangeError otherwise, which then enters the error path. req.body is undefined without a parser.

Express 5.1.0 (2025-03-31)

Express 5 becomes the default latest on npm. Nothing about the error path changed.

Express 5.2 (2025-12-01) and 5.3 (2026-10-09)

Dependency and security updates; the error semantics are unchanged. Read History.md before upgrading.

RFC 9457 (July 2023)

Obsoletes RFC 7807 for problem details. Existing application/problem+json implementations stay compatible, since the members are the same; the new RFC adds guidance on multiple problems and registers a registry of problem types.

Where the books are outdated

The four books consulted for this section target Express 4.0 to 4.18 and Node.js 0.10 to 20. Their error handling is mostly minimal, and several habits have since been replaced:

Older practice Where it appears Current behaviour

A try/catch around the body of every async route handler to call next(err).

Bugl, chapters 3 and 6

Not needed in Express 5: a throw or rejection in an async function reaches the error handlers. Keep try/catch only to translate or recover from a specific error.

No central error handler: each route answers its own failures, or errors fall through to the default HTML page.

Bugl, chapter 6; Sharma, chapter 2

Register one JSON error handler last. Without it, an express-jwt 401 or a body-parser 400 reaches the client as an HTML page.

if (err) res.send(err) followed by more code, so the handler responds twice.

Sharma, chapter 2; Monteiro 2014

Produces ERR_HTTP_HEADERS_SENT. Write return next(err) and let the error handler respond.

Sending the error object or its message straight to the client, or showing stacks in a template.

Monteiro 2016, chapter 7

Leaks internals and, through an unescaped template tag, can inject script. Expose only what expose allows, log the rest.

Packages and shims that patch Express to catch async errors.

Community tutorials of the Express 4 era

Obsolete on Express 5 (see Why express-async-errors and handler wrappers are no longer needed).

app.listen() with no error handling.

Bugl, chapter 3

In Express 5 the callback receives an error argument (for example EADDRINUSE) that should be rethrown or logged.

Ad hoc error shapes ({ error: 'message' }, { success: false }).

All four books

Prefer RFC 9457 application/problem+json, so that clients and tools can handle errors uniformly.

Common pitfalls

Symptom Usual cause Fix

A custom error handler is never called.

It declares three parameters, it is registered before the failing layer, or the error is thrown in a callback outside any promise.

Declare (err, req, res, next), register it last, and forward callback errors with next(err).

The response is HTML while the client expects JSON.

No error handler matched, so finalhandler answered.

Register a JSON error handler, and a 404 layer.

The process exits on a rejected promise on Express 4.

Express 4 does not watch returned promises.

Upgrade to Express 5, or wrap handlers on Express 4.

Clients see the stack trace.

NODE_ENV is not exactly production, or errorhandler is enabled.

Set NODE_ENV=production; mount errorhandler only in development.

ERR_HTTP_HEADERS_SENT from inside the error handler.

The handler writes to a response that has already started.

Check res.headersSent first and call next(err).

Every error is a 500.

The error has no status, or a status outside 400 to 599.

Create errors with http-errors or a class that sets status.

await next() does not catch downstream errors.

next() returns undefined, not a promise for the downstream layers.

Handle errors in a registered error handler instead.

The error handler runs twice for one request.

It is mounted twice, or one error handler calls next(err) and another repeats the logging.

Log in exactly one handler.