Express Error Handling: The Signature, the Stack, and the Async Gap
Express error middleware fails silently when the signature is wrong or registration order is reversed. Learn the four-argument contract, how async errors differ between Express 4 and 5, and a worked JSON API pattern.
06 Dec 2025, 08:30 UTC

You added error-handling middleware to your Express app, but some requests still hang, others return the default HTML error page, and your JSON API occasionally serves stack traces to users. The problem is rarely the logic inside the handler. It is usually the handler's signature, where it sits in the middleware stack, or an assumption about async errors that changed between Express major versions.
The useful takeaway: Express error middleware works only when the callback has exactly four parameters, it is registered after the routes it covers, and you know whether your project runs Express 4 or 5. Get those three things right and one terminal handler can produce uniform JSON errors for validation failures, malformed request bodies, and unexpected exceptions.
The signature and the stack
Express does not identify error-handling middleware by name, comments, or file location. It counts parameters. A callback with exactly four arguments—err, req, res, next—is treated as an error handler. Anything with fewer parameters is ordinary middleware and will never receive an error, no matter how it is named.
This is a common silent misconfiguration. A refactor that removes an unused parameter, or a TypeScript signature that omits err, turns an error handler into dead code. The request continues through the stack until it hits Express's built-in final handler, which sends a generic response instead of your custom JSON.
Registration order matters just as much. Express matches middleware in the order they are registered. An error handler mounted before a route cannot catch that route's errors. If a route throws and the only error handler sits above it, the error falls through to the default final handler.
The built-in final handler derives a response status from err.status or err.statusCode, defaulting to 500. When NODE_ENV is not 'production', it includes the stack trace in the response. In production it sends a minimal message. A custom handler replaces this entirely, which is why teams usually write one—but only if it is registered last.
The async divide: Express 4 versus 5
The most consequential difference between the major versions is how asynchronous errors travel. In Express 4, a rejected promise returned from an async route handler is not forwarded to error middleware automatically. If the handler throws without calling next(err), the request hangs and the rejection may crash the process. This gap motivated wrapper utilities and try/catch blocks around every async handler.
Express 5 changes this. Rejected promises returned from async handlers are forwarded to error-handling middleware automatically. A thrown or rejected error reaches the same centralized handler without extra plumbing.
Because the behavior differs sharply, confirm which major version your project pins before relying on either pattern. Run this in your project root directory; it is read-only and requires no special permissions:
npm ls express
Expected check: the output should show a single installed version, such as express@4.18.2 or express@5.x.x. If you see invalid or multiple extraneous entries, resolve duplicate installs before testing error behavior.
Worked example: one handler for a JSON API
Consider a POST /users endpoint that validates the request body. The goal is a consistent JSON error shape for both bad input and unexpected failures, without leaking internals.
First, define a small error class that carries a status:
class ValidationError extends Error {
constructor(message) {
super(message);
this.status = 400;
}
}
Next, the route. The try/catch and next(err) make this safe on Express 4; on Express 5 the try/catch is optional but harmless:
app.post('/users', async (req, res, next) => {
try {
const { email } = req.body;
if (!email || !email.includes('@')) {
throw new ValidationError('A valid email is required');
}
// Persist user, then:
res.status(201).json({ id: 'user_123' });
} catch (err) {
next(err);
}
});
Finally, the terminal middleware. It must be registered after the route, have exactly four parameters, and never send raw error objects to the client:
app.use((err, req, res, next) => {
console.error(err); // Full error logged server-side
const status = err.status || err.statusCode || 500;
// Only expose messages for client errors (4xx); hide 5xx details
const message = status < 500 ? err.message : 'Internal Server Error';
res.status(status).json({ error: message });
});
Framework-generated errors—such as malformed JSON from body parsing—arrive here as error objects with a status around 400 and a type property. The same code path handles them and application errors, which is the main advantage of centralization.
Trade-offs and limitations
Centralized error handling keeps response shapes consistent, but it can hide context. A handler that always returns 500 for unexpected errors is safe, yet it makes debugging harder unless you log aggressively on the server.
The largest risk is information leakage. If you pass err.message directly to the client for every error, internal details—database strings, file paths, validation logic—become public. The status check in the example above is a simple guard: only messages for client errors reach the client, while server errors return a generic string.
Another limitation is fragility. The four-argument signature is easy to break accidentally during refactors, especially in TypeScript where parameter types are explicit but the count is not enforced by the compiler. A handler with three parameters fails silently, and the only symptom is that your custom error responses stop appearing.
A practical check
To confirm the ordering rule in your own project, temporarily move the error handler above the POST /users route and trigger a validation failure. The error should bypass your handler and hit the default final handler instead. Move it back below the route and the same failure should return your JSON shape.
To verify the async behavior, check your Express version with npm ls express. On Express 4, remove the next(err) call from the catch block and observe the request hang. On Express 5, remove the entire try/catch and confirm the rejection still reaches the error middleware. These two experiments take minutes and prevent the most common silent failures in Express error handling.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.