Stopping the Crash: Handling Async Errors in Express
Learn why async route handlers in Express 4 can crash your server and how to implement a robust asyncHandler wrapper or migrate to Express 5 for automatic error forwarding.
16 Jul 2025, 04:47 UTC

The Silent Failure of Async Routes
In a standard Express route, a synchronous error is easy to manage. If you throw an error inside a handler, Express catches it and passes it to your error-handling middleware. However, the moment you mark a handler as async, that safety net disappears in Express 4.x.
When an async function encounters an error (a rejected promise), it does not "throw" in a way that the Express 4.x router can see. Instead, the promise rejects silently. Because the framework isn't aware of the rejection, the request typically hangs until the client times out. Worse, since Node.js 15, unhandled promise rejections can terminate the entire process, meaning a single failed database query in one route can crash your entire server for every user.
The Version Divide: Express 4 vs. Express 5
The way you solve this depends entirely on your version of Express. You can check your current version by running npm ls express in your project root.
Express 4.x: Manual Forwarding
In version 4, you are responsible for catching every single async error and passing it to the next() function. If you forget to wrap a block in a try/catch, you risk a process crash.
Express 5.x: Automatic Integration
Express 5 changes the contract. Route handlers and middleware that return a promise (which all async functions do) are now automatically monitored. If the promise rejects, Express 5 forwards that rejection to the error-handling middleware automatically, making async handlers behave like synchronous ones.
Implementing a Robust Async Wrapper
If you are on Express 4, writing try/catch in every single route is repetitive and error-prone. A common engineering pattern is the asyncHandler wrapper. This higher-order function wraps your logic and ensures any rejection is caught and sent to next().
// asyncHandler.js
const asyncHandler = fn => (req, res, next) => {
return Promise.resolve(fn(req, res, next)).catch(next);
};
module.exports = asyncHandler;
Here is how to apply it to a route. Run this in a Node environment with express@4 installed:
const express = require('express');
const asyncHandler = require('./asyncHandler');
const app = express();
// Simulated database call that fails
const getUserFromDb = async (id) => {
if (id === 'fail') throw new Error('Database connection lost');
return { id, name: 'Jane Doe' };
};
// Wrapped route: Errors are automatically sent to next()
app.get('/user/:id', asyncHandler(async (req, res, next) => {
const user = await getUserFromDb(req.params.id);
res.json(user);
}));
// Error-handling middleware (Must have 4 arguments)
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).send('Something broke!');
});
app.listen(3000);
Verification and Risks
- Test: Request
/user/fail. With the wrapper, you receive a 500 response. Without the wrapper, the request hangs and the Node process may exit. - Permissions: No special permissions are required; this is standard JavaScript logic.
- Risk: If you use a library like
express-async-errors, it monkey-patches the Express internals to provide this behavior globally. While convenient, this adds a dependency that must be verified for compatibility during major version upgrades.
Critical Limitation: The Header Trap
Regardless of your version or wrapper, there is one hard limit: the response state. Once you have called res.send(), res.json(), or res.writeHead(), the HTTP headers are sent to the client. If an async error occurs after this point (e.g., during a streaming operation or a late-stage cleanup task), the error middleware cannot change the status code to 500.
In these cases, attempting to call res.status(500) will result in an "Error [ERR_HTTP_HEADERS_SENT]" crash. For errors occurring after headers are sent, you must log the error and manually terminate the connection using res.end() or by destroying the socket.
Decision Summary
To ensure your server remains stable under async load, choose the strategy that matches your environment:
- Express 5: Use
async/awaitnaturally; the framework handles the rejections. - Express 4 (Explicit): Use the
asyncHandlerwrapper for every async route to avoid boilerplate and process crashes. - Express 4 (Implicit): Use
express-async-errorsif you prefer global behavior over explicit wrapping, but track its maintenance status.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.