An error handler is a middleware with four parameters. Express 24,430 checks fn.length === 4 to tell it apart from an ordinary layer, so the unused next must stay in the signature. Register it last, below the not-found layer of Mounting and 404s; everything above can then throw, and in Express 5 a rejecting async handler arrives here with no wrapper (Async Middleware).
app.use((req, res, next) => next(new AppError(`No route for ${req.method} ${req.originalUrl}`,
{ status: 404, code: 'route_not_found' })));
app.use((err, req, res, next) => {
const status = Number.isInteger(err.status) && err.status >= 400 ? err.status : 500;
if (status >= 500) logger.error({ err, method: req.method, url: req.originalUrl });
if (res.headersSent) return next(err); // hand back to finalhandler
const message = status >= 500 ? 'Internal Server Error' : err.message;
res.status(status).json({
error: { code: err.code ?? 'internal_error', message,
...(err.details ? { details: err.details } : {}) },
requestId: req.id
});
});Three GET responses from the running Bookshelf server, with the trailing requestId field trimmed for width, followed by the log line the third one writes to stderr:
404 /api/books/99 {"error":{"code":"not_found","message":"Book 99 does not exist"}}
404 /api/authors {"error":{"code":"route_not_found",
"message":"No route for GET /api/authors"}}
500 /api/reports/sales {"error":{"code":"internal_error","message":"Internal Server Error"}}
{"level":"error","msg":"connect ECONNREFUSED 127.0.0.1:27017","name":"Error","status":500,
"method":"GET","url":"/api/reports/sales","stack":"at .../server.js:45:37"}The 500 is the one that matters. Its handler awaited a rejected promise carrying a connection failure; the client learns nothing, the detail goes to the log. Never derive a client message from err.message at 500 and above: internal messages quote table names, file paths and connection strings, and an attacker reads them as a map.
Omitting this middleware is worse than it looks. Express's built-in handler answers with an HTML page, and with NODE_ENV unset that page was 1,885 bytes holding the full stack, absolute file paths and node_modules frames. NODE_ENV=production shrank it to 148 bytes reading Internal Server Error — one environment variable between a stranger and your directory layout.