Centralizing Error Handling in Express with Middleware Chaining
How Express's four-argument error middleware works, why it must be mounted last, and a complete asyncHandler pattern with curl commands to verify both error and success paths.
15 Oct 2025, 18:35 UTC

Every Express route that does real work can fail: a database query times out, a JSON body fails validation, an external API returns 502. If each route formats its own error response, you end up with five slightly different error shapes across your API, and the sixth route forgets to handle errors at all. The fix is a single error-handling middleware mounted after your routes, fed by next(err) from anywhere in the chain.
How Express decides what runs
Express middleware is a chain. Each function receives (req, res, next) and either ends the request (by sending a response) or calls next() to pass control along. The detail that makes centralized error handling work: a middleware declared with four parameters, (err, req, res, next), is treated specially. Normal middleware is skipped when an error is in flight, and error middleware is skipped during normal flow. Calling next(err) with any argument jumps the chain directly to the first four-argument handler registered after the current point.
That last phrase matters: order is everything. If you mount the error handler before a route, it will never see that route's errors. Register it last, after every route and after any 404 handler.
A minimal working setup
Here is a complete pattern you can adapt. It assumes Express 4.x (the dominant version in production; see the caveat about Express 5 below):
const express = require('express');
const app = express();
app.use(express.json());
// Wrap async handlers so rejections reach the error middleware
const asyncHandler = fn => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) {
const err = new Error('User not found');
err.status = 404;
throw err; // asyncHandler forwards this via next(err)
}
res.json(user);
}));
// 404 for unmatched routes
app.use((req, res, next) => {
const err = new Error('Not found');
err.status = 404;
next(err);
});
// Central error handler — four args, mounted LAST
app.use((err, req, res, next) => {
const status = err.status || 500;
if (status === 500) console.error(err); // log unexpected failures
res.status(status).json({
error: {
message: status === 500 ? 'Internal server error' : err.message,
status
}
});
});
app.listen(3000);
Two things to notice. First, the asyncHandler wrapper is essential in Express 4: a rejected promise inside an async route handler does not automatically reach the error middleware — the request hangs until the client times out, and you may get an unhandled rejection warning. The wrapper converts rejections into next(err) calls. Second, the handler deliberately hides internal error messages on 500s so stack details don't leak to clients, while passing through intentional messages (like "User not found") that you set yourself via err.status.
Verifying it actually works
Start the server locally (no special permissions needed — any port above 1024 works), then exercise both paths with curl:
# Expect: HTTP 404 with {"error":{"message":"User not found","status":404}}
curl -i http://localhost:3000/users/999
# Expect: HTTP 404 with {"error":{"message":"Not found","status":404}}
curl -i http://localhost:3000/no-such-route
Also hit a route that succeeds normally and confirm the response is untouched — this proves the error middleware only intercepts failures and isn't mangling ordinary traffic. Finally, temporarily add a route that throws without a status property and confirm you get a 500 with the generic message, not the raw error text. If any of these requests hang instead of responding, the usual culprit is an async path that forgot the wrapper or a middleware that neither responded nor called next.
Trade-offs and limitations
- One handler, many error types. As the API grows, the central handler tends to accumulate
if (err instanceof ValidationError)branches. That's fine up to a point; past that, consider mapping error classes to status codes at throw time (as theerr.statusconvention does) rather than type-checking in the handler. - Express version matters. Express 5 (which moved to a stable release line more recently) forwards rejected promises from async handlers to the error middleware automatically, making
asyncHandlerunnecessary. Check your installed version withnpm ls expressbefore deciding whether you need the wrapper — and if you're on 4.x, don't assume the upgrade is drop-in; 5 has other breaking changes worth reviewing. - Errors in the error handler itself. If your error middleware throws (say, a logging call fails), Express falls back to its default handler, which returns an HTML stack trace in development. Keep the handler's logic boring and defensive.
- Streaming responses. Once headers are sent, you can't change the status code. If an error occurs mid-stream, the handler can only destroy the connection — design endpoints that stream to tolerate this.
Where to go from here
Start with the pattern above: one four-argument handler mounted last, an asyncHandler wrapper if you're on Express 4, and a convention of attaching status to errors you throw deliberately. Then add a failing route to your test suite that asserts the JSON error shape — that single test will catch the two most common regressions: someone mounting a route after the error handler, and someone removing the wrapper from an async route.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.