Centralized Error Handling in Express
Scattering try/catch and res.status(500).send(...) across every route handler is a maintenance trap — the formatting drifts, some paths leak stack traces, and others forget to respond at all. Express solves this with a dedicated kind of middleware that takes four arguments and runs only when an error is forwarded to it. By funneling every failure through one place, you get a single, consistent error response shape and one spot to wire up logging. This page shows how the four-argument signature works, how to forward errors with next(err), how to capture errors from async handlers, and how to build a centralized response format.
The four-argument error middleware
Express distinguishes ordinary middleware from error-handling middleware purely by arity. A function declared with four parameters — (err, req, res, next) — is treated as an error handler and is skipped during normal request flow. It only executes when something upstream calls next() with an argument.
import express from 'express';
const app = express();
app.get('/users/:id', (req, res) => {
res.json({ id: req.params.id, name: 'Ada' });
});
// Error-handling middleware — note the four parameters
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Internal Server Error' });
});
app.listen(3000);
Because Express detects error handlers by counting parameters, you must keep all four — even if you do not use next. Dropping it to (err, req, res) turns the function back into a regular middleware and it will never receive errors.
Register error middleware last, after all routes and other
app.use()calls. Express runs middleware in order, so an error handler placed before your routes can never catch errors they throw.
Forwarding errors with next(err)
Inside a synchronous handler, a plain throw is caught automatically and routed to your error middleware. But the canonical, always-works mechanism is to pass the error to next. Calling next(err) with any truthy argument tells Express to skip every remaining normal handler and jump straight to the error chain.
app.get('/orders/:id', (req, res, next) => {
const order = findOrder(req.params.id);
if (!order) {
const err = new Error('Order not found');
err.status = 404;
return next(err); // hand off to the error handler
}
res.json(order);
});
The error handler can then read your custom properties — like err.status — to shape the response instead of always returning 500.
Handling async route errors
This is the classic Express gotcha. A rejected Promise inside an async handler is not automatically forwarded. If you await something that throws and do nothing, the request simply hangs until the client times out.
// BROKEN — the rejection is never passed to Express
app.get('/report', async (req, res) => {
const data = await buildReport(); // if this throws, request hangs
res.json(data);
});
Express 4 ignores rejected Promises returned from handlers. Express 5 (stable since 2024) changed this: a returned Promise that rejects is automatically passed to
next, so the wrapper below is unnecessary there. The pattern still matters for the large installed base of Express 4 apps.
On Express 4, the fix is to catch and forward explicitly, or wrap handlers in a helper so you never forget.
// A reusable wrapper that forwards any async rejection to next()
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get('/report', asyncHandler(async (req, res) => {
const data = await buildReport(); // a throw here now reaches the error handler
res.json(data);
}));
Promise.resolve(...).catch(next) works because next is a function that accepts the rejection value as its argument — exactly the next(err) call you would write by hand.
A centralized error response format
With every error flowing to one handler, you can enforce a uniform JSON envelope and decide what to expose. Read a status (or statusCode) off the error, default to 500, and hide internal details in production.
import express from 'express';
const app = express();
app.use(express.json());
app.get('/widgets/:id', (req, res, next) => {
const err = new Error('Widget does not exist');
err.status = 404;
err.code = 'WIDGET_NOT_FOUND';
next(err);
});
// Centralized handler — single source of truth for error responses
app.use((err, req, res, next) => {
const status = err.status ?? 500;
const isServerError = status >= 500;
if (isServerError) {
console.error(`[${req.method} ${req.path}]`, err.stack);
}
res.status(status).json({
error: {
message: isServerError ? 'Internal Server Error' : err.message,
code: err.code ?? 'INTERNAL_ERROR',
status,
},
});
});
app.listen(3000, () => console.log('Listening on http://localhost:3000'));
Requesting GET /widgets/42 returns a clean, predictable body:
Output:
HTTP/1.1 404 Not Found
Content-Type: application/json
{
"error": {
"message": "Widget does not exist",
"code": "WIDGET_NOT_FOUND",
"status": 404
}
}
Note the isServerError guard: client errors (4xx) echo a helpful message, while 5xx responses return a generic string so you never leak stack traces or internal messages to callers. Pair this with custom error classes so each error already carries its own status and code, keeping the handler tiny.
If headers were already sent when an error fires, you cannot start a new response. Express’s default handler checks
res.headersSentand delegates to the built-in handler in that case — do the same in custom handlers:if (res.headersSent) return next(err);.
| Concern | Where to handle it |
|---|---|
| Mapping error to HTTP status | Set err.status at throw site; read it in the handler |
| Hiding internals on 5xx | Branch on status >= 500 in the handler |
| Async rejections (Express 4) | asyncHandler wrapper or .catch(next) |
| Async rejections (Express 5) | Automatic — returned rejected Promises go to next |
| Logging | Once, inside the centralized handler |
Best Practices
- Register the error-handling middleware last, after all routes, so it can catch everything upstream.
- Always declare the full
(err, req, res, next)signature — Express identifies error handlers by their four parameters. - Forward errors with
next(err)(or.catch(next)); never callresdirectly from a handler andnext(err)for the same request. - On Express 4, wrap async handlers with an
asyncHandlerhelper so rejected Promises reach the error middleware instead of hanging. - Attach a
statusand a stablecodeto errors so the central handler can map them to the right HTTP response. - Return generic messages for 5xx errors and never expose
err.stackto clients in production. - Guard against double responses with
if (res.headersSent) return next(err);at the top of the handler.