Error-Handling Middleware
Express recognizes error-handling middleware by a single distinguishing feature: its function takes four arguments — (err, req, res, next) — instead of the usual three. When any handler calls next(err) or (in Express 5) rejects an async promise, Express skips the rest of the normal stack and jumps straight to your four-argument handlers. Centralizing that logic in one place gives you consistent JSON responses, a single point for logging, and the ability to hide implementation details from clients in production.
The four-argument signature
Express counts the arity of every function you register. A function with exactly four parameters is treated as an error handler; anything else is normal middleware. This is why the err parameter must be present even if you reference it via _err — removing it silently turns your handler back into ordinary middleware that never receives errors.
const express = require('express');
const app = express();
app.get('/users/:id', async (req, res, next) => {
const user = await db.findUser(req.params.id);
if (!user) {
const err = new Error('User not found');
err.status = 404;
return next(err); // forward to the error handler
}
res.json(user);
});
// Error-handling middleware — note the four arguments
app.use((err, req, res, next) => {
res.status(err.status || 500).json({ error: err.message });
});
Tip: The four-argument signature is positional, not name-based. Even if you do not use
next, you must still declare it so Express sees four parameters.
Register it last
Error handlers must be registered after all routes and other middleware. Express walks the stack in registration order, so a handler defined too early would never be reached by errors thrown in later routes. Define your routes, mount your routers, and only then call app.use with the four-argument handler.
app.use('/api/users', usersRouter);
app.use('/api/orders', ordersRouter);
// 404 handler — runs when no route matched (normal middleware, 3 args)
app.use((req, res, next) => {
const err = new Error('Not Found');
err.status = 404;
next(err);
});
// Centralized error handler — always last
app.use((err, req, res, next) => {
// handled below
});
Sending JSON errors with a status
A robust handler derives the HTTP status from the error, logs the failure, and returns a predictable JSON shape. Default to 500 when no status is attached so unexpected bugs still surface cleanly.
app.use((err, req, res, next) => {
// If headers were already sent, delegate to Express's default handler
if (res.headersSent) return next(err);
const status = err.status || err.statusCode || 500;
// Log full details server-side
console.error(`[${req.method} ${req.originalUrl}]`, err);
res.status(status).json({
error: {
message: err.message,
status,
},
});
});
Output:
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
{"error":{"message":"User not found","status":404}}
Logging and hiding stack traces in production
In development a stack trace is invaluable; in production it leaks internal paths, dependency versions, and logic that helps attackers. Gate the verbose details behind NODE_ENV. Log the full error on the server regardless, but return only a safe message to the client — and never expose a stack trace for unhandled 500s.
app.use((err, req, res, next) => {
if (res.headersSent) return next(err);
const status = err.status || 500;
const isProd = process.env.NODE_ENV === 'production';
// Always log server-side (replace console with pino/winston in real apps)
console.error(err);
const body = {
error: {
// For 5xx in production, hide the real message behind a generic one
message: status >= 500 && isProd ? 'Internal Server Error' : err.message,
status,
},
};
// Only include the stack outside production
if (!isProd) body.error.stack = err.stack;
res.status(status).json(body);
});
Warning: Client-facing
4xxmessages (validation, not-found) are usually safe to surface, but5xxmessages often contain internal detail. Always replace them with a generic message in production.
Behavior reference
| Concern | Rule |
|---|---|
| Signature | Must be (err, req, res, next) — exactly four parameters |
| Registration order | After every route, router, and the 404 handler |
| Triggering | next(err), a sync throw, or a rejected async promise (Express 5) |
| Status code | Read err.status / err.statusCode, default to 500 |
| Already-sent response | Check res.headersSent and return next(err) to delegate |
| Multiple handlers | Allowed — each must call next(err) to pass control along |
Express 4 vs 5
In Express 4, only synchronous throws and explicit next(err) calls reach the error handler; a rejected promise in an async route hangs unless you forward it. Express 5 awaits handler results and auto-forwards rejections to your four-argument middleware. The handler itself is identical in both versions — only how errors reach it differs.
Best Practices
- Register the four-argument handler last, after all routes, routers, and a 404 handler.
- Keep the
errandnextparameters even when unused so Express recognizes the four-argument arity. - Always check
res.headersSentand delegate withnext(err)to avoid “headers already sent” crashes. - Derive the status from
err.status/err.statusCode, defaulting to500for unexpected errors. - Log the full error server-side with a structured logger, but return only safe messages to clients.
- Hide stack traces and internal
5xxmessages whenNODE_ENV === 'production'. - Use a single centralized handler instead of repeating
res.status().json()in every route.