The first Express.js async error handling bug I shipped to production took down the API for about ninety seconds before our health check restarted the process. The handler called an async function, the function threw, the throw propagated as an unhandled rejection, and the process died. Total visible damage: a 502 spike on the dashboard and a Slack message from the on-call engineer that started with “uh.”
That bug is the canonical Express trap. Async functions throw promises, not exceptions. Express 4 never noticed. Express 5 finally fixed the default. The story below is what actually works in 2026 — Express 5 native, the express-async-errors fallback for legacy apps, typed errors, a single error middleware that handles every shape, and the integration with Sentry that survives a bad deploy.
The error in 90 seconds: why your async handler crashed the server
The classic trap, in Express 4:
// THIS CRASHES THE SERVER on Express 4
app.get('/users/:id', async (req, res) => {
const user = await db.user.findUnique({ where: { id: req.params.id } });
if (!user) throw new Error('Not found'); // unhandled rejection
res.json(user);
});Express 4’s router only catches synchronous throws. When the async function rejects, Node sees an unhandled promise rejection. Default behaviour in Node 15+: log a warning and (in stricter configs) crash the process. Either way, the request hangs until your client times out.
The wrong fix everyone tries first: wrap every handler in a try/catch.
// Works. Also: 200 routes, 200 try/catch blocks, easy to miss one.
app.get('/users/:id', async (req, res) => {
try {
const user = await db.user.findUnique({ where: { id: req.params.id } });
if (!user) return res.status(404).json({ error: 'Not found' });
res.json(user);
} catch (err) {
res.status(500).json({ error: 'Internal' });
}
});Two failures with that pattern: error handling lives in every handler instead of one place, and the response shape drifts route by route until your API has six different error contracts. We can do better in three different ways depending on which Express major you are on.
Express 5: native async support, with one caveat
Express 5 shipped in late 2024 and finally catches rejected promises from async route handlers. You write the handler, throw whatever you want, and Express forwards the error to your error middleware automatically.
npm install express@5 express-async-errors # async-errors not needed on 5, but keep reading// app.ts — Express 5
import express from 'express';
const app = express();
app.use(express.json());
app.get('/users/:id', async (req, res) => {
const user = await db.user.findUnique({ where: { id: req.params.id } });
if (!user) throw new Error('Not found'); // caught automatically in Express 5
res.json(user);
});
// Error middleware (4-arg signature is required for Express to recognise it)
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: err.message });
});
app.listen(3000);The caveat: Express 5’s automatic catching only applies when the handler returns a promise. If you call setTimeout(() => { throw new Error() }, 100) from inside a handler, that throw still escapes Express’s net — it executes after the route function has already returned. The fix for that case is the same as Express 4: route async work through the promise chain, never via fire-and-forget timers.
Express 4 with express-async-errors: the one-line patch
If you cannot upgrade to Express 5 (some apps have decade-old middleware that breaks on the upgrade), express-async-errors monkey-patches the router to do exactly what Express 5 does natively. One import, top of your entry file:
// app.ts — Express 4 with the patch
import 'express-async-errors'; // must come before express
import express from 'express';
const app = express();
// ... rest is identical to Express 5 example aboveThe patch is twelve lines of code, has been stable since 2018, and has zero runtime cost. There is no good reason to write try/catch in every Express 4 handler.
The asyncHandler wrapper: when neither option fits
Some teams will not add a monkey-patch dependency and cannot upgrade Express. Third option: a tiny wrapper that catches the rejection and forwards to next().
// src/lib/async-handler.ts
import type { Request, Response, NextFunction, RequestHandler } from 'express';
type AsyncHandler = (req: Request, res: Response, next: NextFunction) => Promise<unknown>;
export const asyncHandler = (fn: AsyncHandler): RequestHandler => (req, res, next) =>
Promise.resolve(fn(req, res, next)).catch(next);app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await db.user.findUnique({ where: { id: req.params.id } });
if (!user) throw new Error('Not found');
res.json(user);
}));Works on every Express version. Adds one wrapper per route. I default to express-async-errors instead because the syntactic noise is lower, but for codebases that already use the wrapper, there is no reason to change.
Typed errors: stop returning 500 for everything
The single biggest quality jump in any Express error story comes from a typed error hierarchy. Make HTTP status codes part of the throw, not part of the catch.
// src/lib/errors.ts
export class HttpError extends Error {
constructor(public status: number, message: string, public code?: string) {
super(message);
this.name = 'HttpError';
}
}
export class NotFoundError extends HttpError {
constructor(message = 'Not found') { super(404, message, 'NOT_FOUND'); }
}
export class UnauthorisedError extends HttpError {
constructor(message = 'Unauthorised') { super(401, message, 'UNAUTHORISED'); }
}
export class ValidationError extends HttpError {
constructor(message: string, public issues?: unknown) {
super(400, message, 'VALIDATION');
}
}
export class ConflictError extends HttpError {
constructor(message = 'Conflict') { super(409, message, 'CONFLICT'); }
}Now route handlers throw the right thing:
app.get('/users/:id', async (req, res) => {
const user = await db.user.findUnique({ where: { id: req.params.id } });
if (!user) throw new NotFoundError(`User ${req.params.id}`);
res.json(user);
});
app.post('/users', async (req, res) => {
const parsed = CreateUserSchema.safeParse(req.body);
if (!parsed.success) throw new ValidationError('Invalid body', parsed.error.issues);
try {
const user = await db.user.create({ data: parsed.data });
res.status(201).json(user);
} catch (err: any) {
if (err.code === 'P2002') throw new ConflictError('Email already exists');
throw err; // unknown DB error, let the middleware surface it
}
});One error middleware to handle everything:
// src/middleware/error-handler.ts
import type { ErrorRequestHandler } from 'express';
import { HttpError } from '../lib/errors';
import { ZodError } from 'zod';
import { logger } from '../lib/logger';
export const errorHandler: ErrorRequestHandler = (err, req, res, _next) => {
// Known typed errors
if (err instanceof HttpError) {
return res.status(err.status).json({
error: { code: err.code ?? 'ERROR', message: err.message, ...(err instanceof ValidationError && { issues: err.issues }) },
});
}
// Zod errors that escaped a handler
if (err instanceof ZodError) {
return res.status(400).json({ error: { code: 'VALIDATION', issues: err.issues } });
}
// JSON body parse errors from express.json()
if (err.type === 'entity.parse.failed') {
return res.status(400).json({ error: { code: 'INVALID_JSON' } });
}
// Everything else: log with context, return generic 500
logger.error({ err, path: req.path, method: req.method }, 'Unhandled error');
res.status(500).json({ error: { code: 'INTERNAL', message: 'Something went wrong' } });
};Wire it last:
app.use(errorHandler); // must come after all routesWhere each piece lives, at a glance
| File | Owns |
|---|---|
src/lib/errors.ts |
Typed error classes (HttpError, NotFoundError, etc.) |
src/middleware/error-handler.ts |
Single 4-arg middleware that maps every error to an HTTP response |
src/lib/async-handler.ts |
(Express 4 only) wrapper that catches promise rejections |
src/lib/logger.ts |
pino logger with request ID context |
| Routes | Throw typed errors. Never write try/catch except around third-party calls. |
Sentry integration: the bit that actually saves you in production
@sentry/node with the Express integration captures errors before your handler can swallow them, plus sets up automatic transaction tracing.
npm install @sentry/node// src/sentry.ts — must be imported FIRST in your entry file
import * as Sentry from '@sentry/node';
Sentry.init({
dsn: process.env.SENTRY_DSN,
environment: process.env.NODE_ENV,
tracesSampleRate: 0.1, // 10% of requests get a trace
beforeSend(event, hint) {
// Don't send 4xx errors — they are expected, not bugs
const err = hint.originalException as any;
if (err?.status && err.status < 500) return null;
return event;
},
});// app.ts
import './sentry'; // FIRST
import express from 'express';
import * as Sentry from '@sentry/node';
import { errorHandler } from './middleware/error-handler';
const app = express();
// ... your routes here
Sentry.setupExpressErrorHandler(app); // BEFORE your error middleware
app.use(errorHandler); // LAST
app.listen(3000);Sentry’s middleware sees the error first, captures it, and passes it down the chain to your error handler — which still formats the HTTP response. The order matters: Sentry → your formatter → done.
The two unhandled cases that still bite
Two error sources Express never sees, no matter what version:
// 1. Fire-and-forget async work
app.post('/upload', async (req, res) => {
res.status(202).json({ queued: true });
processInBackground(req.body); // throws? you'll never know.
});
// 2. setImmediate / setTimeout / setInterval
app.get('/scheduled', (req, res) => {
setTimeout(() => {
throw new Error('boom'); // Express has long since returned
}, 100);
res.json({ scheduled: true });
});Catch them at the process level:
// src/process-handlers.ts
import { logger } from './lib/logger';
import * as Sentry from '@sentry/node';
process.on('unhandledRejection', (reason) => {
logger.error({ reason }, 'Unhandled rejection');
Sentry.captureException(reason);
// Don't exit. Log, alert, fix.
});
process.on('uncaughtException', (err) => {
logger.fatal({ err }, 'Uncaught exception');
Sentry.captureException(err);
// Process state is unknown. Restart cleanly.
process.exit(1);
});Different shutdown behaviour by design: rejections are usually recoverable (one async branch failed), uncaught synchronous exceptions are not (your process state is corrupt). PM2 or systemd brings you back up after the exit. If you are deploying behind PM2, the restart is invisible to clients — assuming the next process can bind the port (EADDRINUSE on restart is the most common reason it can’t).
Production checklist
- Express 5, or Express 4 with
express-async-errorsimported first. - One error middleware, 4-argument signature, registered last.
- Typed error hierarchy in
src/lib/errors.tscovering 400, 401, 403, 404, 409 at minimum. - Routes throw typed errors; they don’t write try/catch except around external API calls.
- Zod validation errors get a dedicated 400 response shape so your frontend can map them to fields.
- Sentry integrated via
setupExpressErrorHandler, withbeforeSendfiltering out 4xx noise. - Process-level handlers for
unhandledRejection(log + alert) anduncaughtException(log + exit). - Logger includes request ID so you can trace a single failed request across logs.
- Health check endpoint never throws and never depends on the database.
- Don’t return error stack traces to the client in production. Code + message only.
When not to use this pattern
Two cases where you should reach for something else:
- You are starting greenfield in 2026. Fastify’s error handler is built around schema validation and typed errors from day one. The Express patterns in this article are catching up to what Fastify shipped years ago.
- You need GraphQL-style errors with multiple errors per response. Express’s error middleware returns one error at a time. For batch operation responses, return 200 with a per-item result array — don’t try to fit it into the error pipeline.
Troubleshooting FAQ
Why doesn’t my error middleware run?
Two usual causes: it’s registered before some routes (must be last), or the middleware function has three arguments instead of four. Express identifies error middleware by the 4-arg signature; the first arg name doesn’t matter, the count does.
What is the difference between throw err and next(err)?
In an async handler on Express 5 or with express-async-errors, they are equivalent. In a sync handler, throw err works. In an async handler on stock Express 4, throw err becomes an unhandled rejection — use next(err) or wrap with asyncHandler.
Should I exit the process on uncaught exception?
Yes. The Node.js docs are explicit: process state after an uncaught exception is undefined, and continuing risks data corruption. Log first, then process.exit(1). Your supervisor (PM2 or systemd) restarts you in seconds.
Does express-async-errors work with TypeScript?
Yes. The import is a side-effect import (import 'express-async-errors';), no types needed. Place it before the Express import in your entry file.
How do I send different error formats to different clients?
Branch on req.accepts() in your error middleware: HTML for browser-driven routes, JSON for application/json requests. Most APIs only need JSON.
What about error handling in async middleware (not just routes)?
Same rules apply. Express 5 and express-async-errors both catch rejections from middleware too. If you write a custom auth middleware that calls await db.session.findUnique(), it works.
Should I retry failed requests inside the handler?
Almost never. Retries belong at the boundary — your HTTP client retrying a downstream API, or the user’s frontend retrying a transient 503. Retrying inside a request multiplies latency and can amplify a downstream incident.
How do I test the error middleware?
Use supertest to make a real HTTP request through your app instance, throw a known error from a test route, assert the response shape and status. Mocking is overkill — Express is small enough to spin up per test.
What ships next
This article fixes the catching side. The next layer is structured logging that ties an error in production back to the exact request that caused it — pino with a request ID middleware and a log shipper. If you are weighing Fastify, its error story is built on similar principles with less ceremony. Validating environment variables at boot kills another whole class of “why did this throw” bugs before the first request lands.