Async Middleware & Error Propagation
Modern Express applications lean heavily on async/await for database queries, HTTP calls, and file I/O. But Express middleware was designed around a synchronous control flow, and a rejected promise inside an async handler does not automatically reach your error-handling middleware in Express 4. Understanding how to bridge that gap — manually in Express 4, automatically in Express 5 — is the difference between clean error pages and silent hangs or crashes.
How async errors escape Express 4
Express decides where to route an error by watching for the next(err) call. When you write a synchronous handler and throw, Express catches it and forwards the error for you. Async functions break this guarantee: an async handler returns a promise, and when that promise rejects, Express has already returned from the function call and never sees the rejection.
// Express 4 — BROKEN: a rejected promise is never forwarded
app.get('/users/:id', async (req, res) => {
const user = await db.findUser(req.params.id); // if this throws...
res.json(user); // ...this line never runs, and Express never gets next(err)
});
If db.findUser rejects, the request hangs until the client times out, and Node logs an UnhandledPromiseRejection. The error handler below never fires.
Warning: A
try/throwin a synchronous handler is caught by Express, but a rejected promise in anasynchandler is not — in Express 4 you must explicitly forward it withnext(err).
Forwarding rejections manually
The portable fix is to wrap the body in try/catch and pass the caught error to next. This works in every Express version.
app.get('/users/:id', async (req, res, next) => {
try {
const user = await db.findUser(req.params.id);
if (!user) {
const err = new Error('User not found');
err.status = 404;
throw err;
}
res.json(user);
} catch (err) {
next(err); // hands control to the error-handling middleware
}
});
A centralized error handler (four arguments, registered last) renders the response:
app.use((err, req, res, next) => {
res.status(err.status || 500).json({ error: err.message });
});
Output:
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
{"error":"User not found"}
A reusable async wrapper
Writing try/catch in every route is repetitive. A small higher-order function captures the pattern: call the handler, and if its returned promise rejects, route the error to next.
// asyncHandler.js
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);
module.exports = asyncHandler;
const asyncHandler = require('./asyncHandler');
app.get(
'/users/:id',
asyncHandler(async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) throw Object.assign(new Error('User not found'), { status: 404 });
res.json(user);
})
);
The handler now reads cleanly — throw anywhere, and the wrapper forwards the rejection. This works for regular middleware too, not just route endpoints.
Express 5 forwards rejections automatically
Express 5 closes the gap. The router awaits the value returned by your middleware and route handlers, so a rejected promise (or a thrown error inside an async function) is forwarded to next(err) for you. No wrapper required.
// Express 5 — the rejection is auto-forwarded to the error handler
app.get('/users/:id', async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) throw Object.assign(new Error('User not found'), { status: 404 });
res.json(user);
});
This applies to app.use middleware and Router-level middleware as well. The behavior only kicks in for functions that return a promise, so synchronous handlers are unaffected.
Tip: Express 5 still does not catch errors thrown in a synchronous callback you pass to something else (for example inside a
setTimeoutor an event listener). For those, catch and callnext(err)yourself.
The express-async-errors helper
If you are on Express 4 and prefer not to wrap every handler, the express-async-errors package patches the Router to await handler results — giving you Express 5-style behavior without upgrading. Import it once, before your routes are defined.
npm install express-async-errors
require('express-async-errors'); // patch must run before routes
const express = require('express');
const app = express();
app.get('/users/:id', async (req, res) => {
const user = await db.findUser(req.params.id);
res.json(user); // rejections now reach the error handler automatically
});
Approach comparison
| Approach | Express version | Boilerplate | Notes |
|---|---|---|---|
Manual try/catch + next(err) | 4 and 5 | High | Explicit, no dependencies, always works |
asyncHandler wrapper | 4 and 5 | Low | One small utility, wrap each handler |
express-async-errors | 4 | None | Global monkey-patch, import once |
| Native auto-forwarding | 5 | None | Built in, the recommended path |
Best Practices
- On Express 5, rely on native auto-forwarding and drop wrappers; on Express 4, standardize on a single
asyncHandlerorexpress-async-errorsso every route behaves consistently. - Always register a four-argument error handler
(err, req, res, next)last, after all routes and middleware. - Attach a
statusproperty to errors so the error handler can choose the correct HTTP status code. - Never mix patterns — choosing both a wrapper and the global patch can double-forward errors.
- Catch errors from callbacks that Express cannot see (timers, streams, event emitters) and call
next(err)manually. - Avoid sending a response after calling
next(err); the error handler owns the response from that point on.