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:
-
The function throws synchronously. Express wraps every layer call in a
try/catchand turns the exception intonext(err). -
The function calls
next(err)with any truthy value, except the two special strings'route'and'router'. -
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).
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;
}
});
Source: Express: Error handling
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;
}
});
Source: Express: Error handling
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
setTimeoutcallback, is outside the returned promise. Use the promise versions of the API (node:fs/promises,node:timers/promises,util.promisify()), or callnext(err)yourself. -
Resolving is not responding. An
asynclayer that finishes without sending a response and without callingnext()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-errorspackage, which is imported once and patches Express internals so that a rejected promise is passed tonext. 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;
}
});
Source: Express: Error handling
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
lengthand 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. Callingnext()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/apiarea 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;
}
});
Source: Express: Error handling
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 |
Headers |
Copies the object in |
Body |
A small HTML page. Outside production the body is the error’s stack trace (HTML-escaped); when |
Security headers |
Sets |
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 |
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;
}
});
Source: Express middleware: errorhandler
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 |
|---|---|
|
A URI reference that identifies the kind of problem and, when dereferenced, should lead to human-readable
documentation. When absent, it is |
|
The HTTP status code, repeated in the body for consumers that receive only the body. It must agree with the real status. |
|
A short human-readable summary of the problem type. It should not change between occurrences of the same type. |
|
A human-readable explanation specific to this occurrence. Clients must not parse it. |
|
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
instancemember 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. -
exposefollows the convention ofhttp-errors: it istrueonly 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
asynclayer would also work in Express 5. -
A malformed JSON body makes
express.json()raise an error of typeentity.parse.failedwith status400, which the normalisation maps to a problem with that status. An oversized body gives413(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 |
|---|---|
|
|
|
|
|
A convention from |
|
Used by libraries to classify an error ( |
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;
}
});
Source: jshttp/http-errors
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 |
|---|---|---|
|
|
Keep the status (400, 413, 415). The body-parser messages are safe to show ( |
Your validation library (Zod, Joi, |
A |
|
|
|
|
PostgreSQL through |
|
|
MongoDB through Mongoose |
|
|
|
|
Constraint violations become |
Network and file system errors |
|
|
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;
}
Source: auth0/express-jwt
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;
}
});
Source: Express API: Response
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
});
Source: Express: Error handling
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
asyncfunction that responds and then continues to a secondres.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,
});
});
}
Source: Express: Error handling
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;
}
});
Source: Express: Error handling
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 |
Express 5.1.0 (2025-03-31) |
Express 5 becomes the default |
Express 5.2 (2025-12-01) and 5.3 (2026-10-09) |
Dependency and security updates; the error semantics are unchanged. Read |
RFC 9457 (July 2023) |
Obsoletes RFC 7807 for problem details. Existing |
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 |
Bugl, chapters 3 and 6 |
Not needed in Express 5: a throw or rejection in an |
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 |
|
Sharma, chapter 2; Monteiro 2014 |
Produces |
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 |
Packages and shims that patch Express to catch async errors. |
Community tutorials of the Express 4 era |
Obsolete on Express 5 (see Why |
|
Bugl, chapter 3 |
In Express 5 the callback receives an |
Ad hoc error shapes ( |
All four books |
Prefer RFC 9457 |
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 |
The response is HTML while the client expects JSON. |
No error handler matched, so |
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. |
|
Set |
|
The handler writes to a response that has already started. |
Check |
Every error is a |
The error has no |
Create errors with |
|
|
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 |
Log in exactly one handler. |
References
-
Publisher pages of the consulted books are listed in the section bibliography.
-
Related pages: Middleware Fundamentals, Validation & Input Handling, Debugging & Logging, Testing