You wrote a clean error middleware, tested it with a synchronous throw, and it worked. Then an await in a route rejects — and instead of a tidy 500, the request hangs and your handler never fires. This is one of the most common Express bugs in production, and it comes down to a single gap in how Express 4 handles promises.
Why the error vanishes
Express 4's routing was written before async/await. It wraps each handler in a try/catch, so a synchronous throw is caught and forwarded to your error middleware. But an async function returns a promise, and Express 4 doesn't await it — so when that promise rejects, Express never sees the error:
// looks fine — but the rejection is invisible to Express 4
app.get("/users/:id", async (req, res) => {
const user = await db.users.findById(req.params.id) // throws → rejects
res.json(user)
})
// no response is ever sent; the request hangs until it times out
The throw becomes an unhandled promise rejection. Your app.use((err, req, res, next) => …) handler is never called because no one passed the error to next.
Fix 1: pass it to next() yourself
The mechanical fix is a try/catch in every handler that forwards the error:
app.get("/users/:id", async (req, res, next) => {
try {
const user = await db.users.findById(req.params.id)
res.json(user)
} catch (err) {
next(err) // now the error reaches your middleware
}
})
Correct, but repetitive — and the day someone forgets the try/catch, the bug is back. It doesn't scale past a couple of routes.
Fix 2: a wrapper (the standard pattern)
Write the try/catch once as a higher-order function, then wrap every async handler with it:
// catch any rejection and forward it — one wrapper, every route
const asyncHandler = (fn) => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next)
app.get("/users/:id", asyncHandler(async (req, res) => {
const user = await db.users.findById(req.params.id)
res.json(user) // any rejection now lands in your error middleware
}))
This is what libraries like express-async-handler do. One small function removes every try/catch and makes it impossible to forget — the rejection always flows to next.
Fix 3: upgrade to Express 5
Express 5 closes the gap: it awaits the promise returned by async middleware and forwards a rejection to your error handler automatically. On Express 5, the naive version just works:
// Express 5 — a plain throw in an async route reaches error middleware
app.get("/users/:id", async (req, res) => {
const user = await db.users.findById(req.params.id)
if (!user) throw new AppError(404, "Not found") // caught for you
res.json(user)
})
If you're starting fresh, this is the cleanest option. On an existing Express 4 app, the asyncHandler wrapper is the low-risk fix.
Don't forget the safety net
Whichever fix you use, keep a process-level guard so a stray rejection can't silently take the server down:
process.on("unhandledRejection", (reason) => {
logger.error({ reason }, "unhandled promise rejection")
// let your process manager restart a truly broken process
})
The takeaway: on Express 4 an async rejection never reaches your error middleware on its own. Wrap your handlers (or move to Express 5), and every failure — sync or async — ends up in the one place that formats it.
FAQ
Why isn't my Express error handler catching errors?
Express 4 only forwards synchronous throws to error middleware. When an async handler rejects, Express doesn't see it — the promise rejection is unhandled and the request hangs. You have to pass the error to next() yourself, or wrap handlers so it happens automatically.
Does Express 5 fix async errors?
Largely, yes. Express 5 automatically forwards rejected promises from async middleware to your error handler, so a plain 'throw' inside an async route works. On Express 4 you still need a wrapper or manual next(err).
What is an asyncHandler wrapper?
A small higher-order function that wraps an async route, catches any rejection, and calls next(err) for you — so one wrapper replaces try/catch in every handler.

