Express Async Error Handling: Why Rejected Promises Hang Requests and How to Centralize Failures
Express 4 silently drops rejected promises from async handlers, hanging requests. A two-line wrapper or Express 5's automatic forwarding centralizes failures into one error middleware.
19 Jan 2026, 08:40 UTC

The request that never finishes
You write an async route handler in Express. It awaits a database call, the connection drops, and the promise rejects. In Express 4, the client does not get a 500. It gets a spinner that never stops. The rejection escapes into Node's unhandledRejection event, and your carefully written error middleware sits idle because Express never called it.
The root cause is simple: Express invokes route handlers synchronously. When your handler is an async function, it returns a promise immediately. Express 4 does not attach a .catch() to that promise. If the promise rejects, Express has already moved on. The request hangs, and the failure is invisible to your error-handling middleware.
What error middleware actually requires
Express identifies error-handling middleware by its function signature. It must declare exactly four parameters: (err, req, res, next). A three-argument function is treated as ordinary middleware and will never receive an error. The middleware must also be registered after your routes, because Express matches middleware in the order it is defined.
Even with the correct signature and placement, Express 4 will not route a rejected promise into that middleware on its own. The rejection must be passed to next() explicitly.
A minimal wrapper that converts rejections to next(err)
You do not need a library to fix this. A small wrapper around your handlers forces rejected promises into Express's error pipeline:
const wrap = fn => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
Use it when defining routes:
app.get('/users/:id', wrap(async (req, res, next) => {
const user = await db.findById(req.params.id);
if (!user) {
const err = new Error('User not found');
err.status = 404;
throw err;
}
res.json(user);
}));
The wrapper calls your handler, normalizes the result to a promise, and forwards any rejection to next. Synchronous throws are caught as well, because Promise.resolve() captures them. This is not a new dependency; it is a two-line function you can paste into your application entry point.
What the wrapper does not catch
The wrapper only sees errors from the handler function itself. If you attach an event listener to res—for example, listening for a client disconnect—and that callback throws, the wrapper cannot intercept it. Those failures still need a process-level backstop.
Centralizing domain errors in one middleware
Once rejections reach next(), you can map them to HTTP responses in a single place. A common pattern is to attach a status code to domain errors and translate them centrally:
app.use((err, req, res, next) => {
const status = err.status || 500;
const payload = {
message: status === 500 ? 'Internal Server Error' : err.message
};
if (process.env.NODE_ENV !== 'production') {
payload.stack = err.stack;
}
res.status(status).json(payload);
});
This handler does three useful things. It logs once (add your logger here). It prevents stack traces from leaking to clients in production. And it keeps status-code logic out of individual routes.
Express 5 changes the default
Express 5 forwards rejected promises from handlers and middleware to error handlers automatically. If you are starting a new project on Express 5, most manual wrapping becomes unnecessary. The migration is not just a version bump, however. Code that relied on rejections being silent—perhaps because a background job was fire-and-forget—will now trigger error middleware. Confirm your installed major version before removing wrappers:
npm ls express
Run this in your project root. No elevated permissions are required beyond read access to node_modules. Expected check: the output shows your direct dependency as express@4.x or express@5.x. Risk: nested packages may also list express; focus on your application's direct dependency before deciding whether wrappers are still needed.
Trade-offs and limitations
Wrapping every handler adds boilerplate and can obscure control flow. A reader must know that wrap is not just a type annotation but a control-flow boundary. Express 5 removes that boilerplate, but it introduces a behavioral break for code that intentionally ignored rejections.
Two other limitations matter in production. First, never call next(err) after you have already sent a response; double responses trigger header errors and can crash the process. Second, your error middleware can itself throw—especially if it logs to an external service. Keep logging defensive, and add a process-level listener as a final backstop:
process.on('unhandledRejection', (reason) => {
console.error('Unhandled rejection:', reason);
// Consider exiting the process here depending on your recovery policy.
});
How to verify the fix
Create a route whose async handler throws, call it with curl, and confirm you receive a 500 from your error middleware rather than a hung request:
curl -i http://localhost:3000/your-test-route
Run from your terminal. Replace /your-test-route with a path that triggers an async throw. Expected check: the response includes HTTP/1.1 500 Internal Server Error and your JSON payload, not a timeout. Risk: if the route does not actually throw, you will test the happy path instead of the error path.
To confirm no rejection escapes, temporarily add an unhandledRejection listener to your application entry point and verify it stays silent when you trigger the throwing route. For automated coverage, a supertest assertion that a known error type maps to the expected status code through the centralized handler gives you regression protection.
If you are on Express 4, the wrapper is a small, dependency-free change that turns silent hangs into visible, centralized failures. If you are on Express 5, verify that the automatic forwarding matches your expectations before deleting the wrapper. Either way, the goal is the same: one place where errors become HTTP responses, logged once, without leaking internals.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.