Express 4 — Async Route Handlers Swallow Errors and Hang Requests
Find out why Express 4 never delivers rejections from async handlers to error middleware, why requests hang instead of failing, and how to wrap handlers correctly.
Symptoms
- The route returns no response and the client times out, while the process logs an unhandledRejection for that request
- Express error-handling middleware (app.use with 4 arguments) never fires for failures inside async handlers
- The same handler works when the failure path is synchronous
Possible causes
- Express 4 passes handler return values to next() only when next is called; a rejected promise is silently dropped
- try/catch inside the handler catches the error but the catch path forgets to call next(err)
- An awaited helper rejects after the response was partially written, leaving the socket open
- The global unhandledRejection handler logs the rejection but nobody responds to the pending request
Diagnosis steps
Log the rejections with stack traces
process.on('unhandledRejection', (err, p) => console.error('unhandled', err));Interpretation
A rejection stack that originates inside a route handler confirms Express dropped it before your error middleware could see it.
Verify handler signature differences
grep -R "app.use((err, req, res, next)" src/ 2>/dev/null || grep -R "err, req, res, next" src/Interpretation
Error middleware must declare all four parameters. A three-argument middleware is treated as a normal handler and never receives errors.
Reproduce with a minimal route
curl -m 5 http://localhost:3000/fail-asyncInterpretation
A timeout with no 500 response, followed by an unhandledRejection log line, reproduces the exact production behavior.
Root cause
Express 4 synchronously invokes handlers and inspects only callback invocation such as next(err). An async function that rejects returns a rejected promise that Express 4 ignores, so neither the error middleware nor the response path runs, and the request socket stays open until the client gives up.
Fix
Wrap every async handler with an asyncHandler that forwards rejections to next, keep the four-argument error middleware last, and for new code prefer Express 5 or a router that natively awaits handler promises.
Risk note
Relying only on the global unhandledRejection handler to close sockets leaves responses to client timeouts and can leak sockets under load.
Verify the fix
Request the failing route and assert a 500 response within milliseconds, one error-middleware log line, and zero unhandledRejection events in the process log.
Symptom walkthrough
The signature is a request that never completes and a process log entry that explains why, two facts that never meet:
2026-10-22T08:14:03.112Z unhandledRejection Error: connection refused
at queryUser (src/routes/users.js:41:19)
The stack points inside the route, but the Express error middleware never logs a thing. That combination — rejection stack from a route, silent error middleware — is the fingerprint of Express 4 dropping the promise.
Fix in practice
Wrap handlers once, centrally, and let the wrapper bridge promises into next:
const wrap = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
app.get('/users/:id', wrap(async (req, res) => {
const user = await queryUser(req.params.id);
if (!user) throw Object.assign(new Error('not found'), { status: 404 });
res.json(user);
}));
// last middleware, all four arguments present
app.use((err, req, res, next) => {
res.status(err.status || 500).json({ error: err.message });
});
Audit existing routes with a grep for async handlers registered without the wrapper. Also check catch blocks that swallow errors instead of calling next(err); they produce the same hanging behavior without any rejection log.
Verification
Hit the previously hanging route with curl -m 5. Before the wrapper you get a timeout and an unhandledRejection; after it you get an immediate 500 plus one error-middleware log line. Then assert the invariant in CI: grep the codebase for async (req and confirm every match sits inside wrap(). Finally keep the unhandledRejection listener as a safety net, but its counter must stay at zero in steady state.