Async Error Handling in Express: Best Practices
Async errors in Express are easy to miss. Here is how to handle them right.
Async Error Handling in Express: Best Practices
Express 4 does not catch promise rejections from async handlers. This is a common source of bugs. Here is how to handle async errors right.
The Problem
router.get('/users/:id', async (req, res) => {
const user = await User.findById(req.params.id);
res.json(user);
});
If User.findById rejects (e.g., database is down), the promise rejects. Express 4 does not catch it. The client hangs. The error is swallowed.
Solution 1: try/catch in Handler
router.get('/users/:id', async (req, res, next) => {
try {
const user = await User.findById(req.params.id);
if (!user) return next(new ApiError(404, 'User not found'));
res.json(user);
} catch (err) {
next(err);
}
});
Works, but boilerplate in every handler.
Solution 2: asyncHandler Wrapper
const asyncHandler = fn => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
router.get('/users/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) throw new ApiError(404, 'User not found');
res.json(user);
}));
The wrapper catches rejections and passes them to next. Now you can throw inside async handlers and they get caught.
Solution 3: Express 5
Express 5 catches async errors automatically. Upgrade if you can. The wrapper is still useful for older versions.
Solution 4: Process-Level Catch
As a safety net, add handlers for unhandled rejections:
process.on('unhandledRejection', (reason) => {
logger.error('Unhandled Rejection', reason);
});
process.on('uncaughtException', (err) => {
logger.error('Uncaught Exception', err);
process.exit(1);
});
These should not be your main strategy, but they prevent silent crashes.
Pattern: Custom ApiError + Wrapper
class ApiError extends Error {
constructor(status, message) { super(message); this.status = status; }
}
router.post('/users', asyncHandler(async (req, res) => {
const existing = await User.findOne({ email: req.body.email });
if (existing) throw new ApiError(409, 'Email already in use');
const user = await User.create(req.body);
res.status(201).json(user);
}));
Reads cleanly. Errors are typed. Status codes are correct.
The Takeaway
Handle async errors in Express with an asyncHandler wrapper that catches promise rejections and passes them to next. Use a custom ApiError class with status. Add process-level unhandledRejection and uncaughtException handlers as a safety net. Upgrade to Express 5 for automatic async error catching.
Wrap async handlers with asyncHandler: Promise.resolve(fn(req, res, next)).catch(next). The wrapper catches promise rejections and passes them to the error handler. Then you can throw inside async handlers.
Express 4 was designed before async/await. It does not catch promise rejections from async handlers. The handler returns a rejected promise that Express ignores, and the client hangs.
Yes. Express 5 catches promise rejections from async handlers and passes them to the error handler. The asyncHandler wrapper is still useful for older versions.
A safety net. process.on('unhandledRejection') and process.on('uncaughtException') catch what your middleware misses. They should not be your main strategy, but they prevent silent crashes. uncaughtException should usually exit the process.
ApiError carries a status and message, so the error handler can return the right status code and a safe message. Combined with asyncHandler, async handlers read cleanly: throw new ApiError(404, 'User not found') and the rest is handled.
Ready to master Node.js completely?
Want to upskill yourself, crack your next interview, and get your dream job? Join our comprehensive course to dive deeper with high-quality video tutorials, solve interview questions, and a premium community.
Master Node.js
Want to upskill yourself, crack your next interview, and get your dream job? Join our comprehensive course.

