
If your Express 4 codebase is full of asyncHandler(...) wrappers, try/catch blocks that end in next(err), or a require('express-async-errors') line at the top of app.js, this post is for you. Express 5 async error handling makes most of that code unnecessary, because the router now catches rejected promises itself. However, not all of it can go. Some async failures still crash the process on Express 5, and one popular wrapper library crashes the app on startup instead of helping. Every behaviour below comes from probe scripts run on Express 4.22.3 and 5.3.0 on the same day, with the output pasted. You will learn which wrappers to delete, which try/catch blocks to keep, and what to add to the error handler before you ship the upgrade.
What Was Tested, and Against Which Versions
Version facts come from the npm registry and the Express changelog, read on the day of writing. The behaviour does not come from the docs. Instead, each failure mode got a small scenario, and every scenario ran in a fresh Node process on both Express lines.
Checked 2026-10-12
Windows 11 Pro (build 26200), x86_64, 15 GB RAM
Node.js 24.21.0 (official win-x64 zip), npm 11.19.0
express 4.22.3 (finalhandler 1.3.2)
express 5.3.0 (router 2.2.0, finalhandler 2.1.1), released 2026-10-09
express 5.2.1 (for the logging comparison only)
express-async-errors 3.1.1, express-async-handler 1.2.0
Changelog https://github.com/expressjs/express/blob/master/History.md
Next scheduled re-check: 2027-04-12, or on the next Express minor release
The three versions sit side by side in one directory through npm aliases, so a single script can load either line by package name:
npm install express4@npm:express@4.22.3 express5@npm:express@5.3.0 express52@npm:express@5.2.1
The results are exit codes and HTTP statuses, not timings, so they do not vary between runs. Still, the full matrix ran three times and the outputs were identical. One limitation applies throughout: everything ran on Windows. However, unhandled rejection behaviour is a Node.js default, not an operating system feature, so Linux and macOS behave the same way.
What Changed in Express 5 Async Error Handling?
In Express 5, a route handler or middleware that returns a rejected promise is treated as if it called next(err). The router waits for the promise, catches the rejection, and passes the error to your error middleware. In Express 4, the same rejection escaped the router entirely and became an unhandled promise rejection in Node.
The change lives in the router package, which Express 5 depends on as router@2, rather than in Express itself. That detail matters later, because it is the reason one popular wrapper library breaks. The official Express 5 migration guide lists it as “rejected promises handled from middleware and handlers”.
So why does this matter now, two years after Express 5.0.0 shipped on 2024-09-10? Because a lot of code never moved. Summing npm’s per-version download counts for the last week shows the split:
curl -s https://api.npmjs.org/versions/express/last-week > ev.json
node -e '
const d = require("./ev.json").downloads; let t = {}, all = 0;
for (const [v, n] of Object.entries(d)) { const m = v.split(".")[0]; t[m] = (t[m] || 0) + n; all += n; }
for (const m of ["5", "4", "3"]) console.log(`express ${m}.x ${String(t[m] || 0).padStart(11)} ${((t[m] || 0) / all * 100).toFixed(1)}%`);
console.log(`all ${String(all).padStart(11)}`);'
express 5.x 80719797 56.1%
express 4.x 63178911 43.9%
express 3.x 59603 0.0%
all 143994672
These counts include transitive installs and CI caches, so they are not a count of live apps. Even so, Express 4 still accounts for 43.9% of a week’s downloads. The Express support page lists both 4.x and 5.x as supported with no end date, so nothing forces the move. The async error handling is the strongest reason to make it anyway.
The Probe: Eleven Ways an Async Handler Fails
Each scenario below is one route on a fresh app. The script sends one request to it, prints what the HTTP client saw, and then checks whether the process is still alive 300 ms later. The database call is a promise that rejects after 5 ms, which stands in for a dropped connection.
// probe.cjs: node probe.cjs <express-package> <scenario>
const [pkg, scenario] = process.argv.slice(2);
const express = require(pkg);
const fs = require('node:fs');
const db = { query: () => new Promise((_, reject) => setTimeout(() => reject(new Error('db down')), 5)) };
const app = express();
const scenarios = {
'sync-throw': () => app.get('/', () => { throw new Error('sync boom'); }),
'async-throw': () => app.get('/', async () => { await db.query(); }),
'async-middleware': () => {
app.use(async (req, res, next) => { await db.query(); next(); });
app.get('/', (req, res) => res.send('ok'));
},
'then-chain': () => app.get('/', (req, res) => db.query().then((rows) => res.json(rows))),
'throw-undefined': () => app.get('/', async () => { throw undefined; }),
'floating-promise': () => app.get('/', async (req, res) => { db.query(); res.send('queued'); }),
'settimeout-throw': () => app.get('/', async (req, res) => { setTimeout(() => { throw new Error('late boom'); }, 5); }),
'callback-throw': () => app.get('/', (req, res) => {
fs.readFile('missing.json', (err, data) => { if (err) throw err; res.send(data); });
}),
'async-param': () => {
app.param('id', async (req, res, next, id) => { await db.query(); next(); });
app.get('/users/:id', (req, res) => res.send('user'));
},
'async-error-handler': () => {
app.get('/', () => { throw new Error('first'); });
app.use(async (err, req, res, next) => { await db.query(); res.status(500).send('handled'); });
},
'after-send': () => app.get('/', async (req, res) => { res.json({ ok: true }); await db.query(); }),
};
scenarios[scenario]();
const server = app.listen(0, async () => {
const path = scenario === 'async-param' ? '/users/42' : '/';
let result;
try {
const r = await fetch(`http://127.0.0.1:${server.address().port}${path}`, { signal: AbortSignal.timeout(1500) });
const body = (await r.text()).replace(/\s+/g, ' ').slice(0, 60);
result = `status=${r.status} body=${JSON.stringify(body)}`;
} catch (e) {
result = `request failed: ${e.name === 'TimeoutError' ? 'timeout after 1500 ms' : e.cause?.code || e.message}`;
}
console.log(result);
setTimeout(() => { console.log('process alive'); server.close(); process.exit(0); }, 300);
});
A short runner executes every scenario on both lines with NODE_ENV=production, then prints the exit code, whether the process survived, the client result and the first error line from stderr:
#!/usr/bin/env bash
# run.sh: runs each scenario on Express 4.22.3 and 5.3.0 in a fresh process
SCENARIOS=${*:-"sync-throw async-throw async-middleware then-chain throw-undefined async-param async-error-handler after-send floating-promise settimeout-throw callback-throw"}
for s in $SCENARIOS; do
echo "## $s"
for pkg in express4 express5; do
NODE_ENV=production node probe.cjs "$pkg" "$s" >out.txt 2>err.txt; code=$?
client=$(grep -E '^(status|request)' out.txt || echo 'no response, process exited')
alive=$(grep -q 'process alive' out.txt && echo alive || echo dead)
err=$(grep -oE '^(\[?[A-Za-z]*Error[^]]*|\[UnhandledPromiseRejection[^.]*)' err.txt | head -1)
printf '%-9s exit=%s %-5s %s\n stderr: %s\n' "$pkg" "$code" "$alive" "$client" "${err:-(empty)}"
done
done
rm -f out.txt err.txt
Express 4 vs Express 5: The Results
Here is the full output. The HTML bodies are cut at 60 characters by the probe, and the file path in the last scenario is shortened:
## sync-throw
express4 exit=0 alive status=500 body="<!DOCTYPE html> <html lang=\"en\"> <head> <meta charset=\"utf-8"
stderr: Error: sync boom
express5 exit=0 alive status=500 body="<!DOCTYPE html> <html lang=\"en\"> <head> <meta charset=\"utf-8"
stderr: Error: sync boom
## async-throw
express4 exit=1 dead no response, process exited
stderr: Error: db down
express5 exit=0 alive status=500 body="<!DOCTYPE html> <html lang=\"en\"> <head> <meta charset=\"utf-8"
stderr: Error: db down
## async-middleware
express4 exit=1 dead no response, process exited
stderr: Error: db down
express5 exit=0 alive status=500 body="<!DOCTYPE html> <html lang=\"en\"> <head> <meta charset=\"utf-8"
stderr: Error: db down
## then-chain
express4 exit=1 dead no response, process exited
stderr: Error: db down
express5 exit=0 alive status=500 body="<!DOCTYPE html> <html lang=\"en\"> <head> <meta charset=\"utf-8"
stderr: Error: db down
## throw-undefined
express4 exit=1 dead no response, process exited
stderr: (empty)
express5 exit=0 alive status=500 body="<!DOCTYPE html> <html lang=\"en\"> <head> <meta charset=\"utf-8"
stderr: Error: Rejected promise
## async-param
express4 exit=1 dead no response, process exited
stderr: Error: db down
express5 exit=0 alive status=500 body="<!DOCTYPE html> <html lang=\"en\"> <head> <meta charset=\"utf-8"
stderr: Error: db down
## async-error-handler
express4 exit=1 dead no response, process exited
stderr: Error: db down
express5 exit=0 alive status=500 body="<!DOCTYPE html> <html lang=\"en\"> <head> <meta charset=\"utf-8"
stderr: Error: db down
## after-send
express4 exit=1 dead status=200 body="{\"ok\":true}"
stderr: Error: db down
express5 exit=0 alive status=200 body="{\"ok\":true}"
stderr: Error: db down
## floating-promise
express4 exit=1 dead status=200 body="queued"
stderr: Error: db down
express5 exit=1 dead status=200 body="queued"
stderr: Error: db down
## settimeout-throw
express4 exit=1 dead no response, process exited
stderr: Error: late boom
express5 exit=1 dead no response, process exited
stderr: Error: late boom
## callback-throw
express4 exit=1 dead no response, process exited
stderr: [Error: ENOENT: no such file or directory, open '.\missing.json'
express5 exit=1 dead no response, process exited
stderr: [Error: ENOENT: no such file or directory, open '.\missing.json'
The same results as a table, which is the short answer to “what does Express 5 catch”:
| Scenario | Express 4.22.3 | Express 5.3.0 |
|---|---|---|
Synchronous throw in a handler | 500, process alive | 500, process alive |
async handler that rejects | Process exits, code 1 | 500, process alive |
async middleware that rejects | Process exits, code 1 | 500, process alive |
Non-async handler returning a rejecting .then() chain | Process exits, code 1 | 500, process alive |
throw undefined in an async handler | Process exits, code 1 | 500, logged as Error: Rejected promise |
async callback in app.param() | Process exits, code 1 | 500, process alive |
async error middleware that rejects | Process exits, code 1 | 500, process alive |
| Rejection after the response was sent | Client gets 200, then process exits | Client gets 200, error logged, process alive |
| Promise started but not awaited | Client gets 200, then process exits | Client gets 200, then process exits |
throw inside setTimeout | Process exits, code 1 | Process exits, code 1 |
throw inside a Node callback | Process exits, code 1 | Process exits, code 1 |
Notice the bottom three rows. Express 5 catches what the handler returns, meaning the promise the router can see. It cannot catch anything that happens outside that promise, because nothing connects the failure to the request any more.
Why Express 4 Crashes Instead of Returning 500
Express 4 calls your handler and ignores its return value. When an async handler rejects, nobody is listening on that promise. Since Node 15, the default --unhandled-rejections=throw mode turns that into an uncaught exception, so the process exits with code 1 and every in-flight request on that instance dies with it.
Many Express 4 apps hide this with a global hook, usually added after the first crash in production. That changes the symptom, not the cause. With process.on('unhandledRejection') in place, the process survives and the request never gets an answer:
// hang.cjs: how long a request stays open when Express 4 loses a rejection
process.on('unhandledRejection', () => {});
const express = require('express4');
const app = express();
app.get('/', async () => { throw new Error('db down'); });
const server = app.listen(0, async () => {
console.log(`requestTimeout=${server.requestTimeout} headersTimeout=${server.headersTimeout} timeout=${server.timeout}`);
const started = Date.now();
try {
await fetch(`http://127.0.0.1:${server.address().port}/`, { signal: AbortSignal.timeout(60000) });
} catch (e) {
console.log(`client gave up after ${Date.now() - started} ms: ${e.name}`);
}
server.close(); process.exit(0);
});
requestTimeout=300000 headersTimeout=60000 timeout=0
client gave up after 60003 ms: TimeoutError
The server did not close the socket within 60 seconds, and the only thing that ended the request was the client’s own timeout. Node’s requestTimeout covers receiving the request, not sending the response, so it never fires here. In production, that shows up as a load balancer 504 with no matching error in the application logs, which is a miserable thing to debug. Running with --unhandled-rejections=warn behaves the same way: a warning on stderr and a request that hangs.
How Express 5 Async Error Handling Behaves at the Edges
The table covers the common cases. Four edge cases are worth knowing before you delete any code.
Rejecting With undefined Still Produces an Error
Code that rejects with no value, such as throw undefined or Promise.reject(), used to crash Express 4 with Node’s generic UnhandledPromiseRejection message and no stack trace pointing at your code. Express 5 replaces the falsy value with new Error('Rejected promise'), so your error handler always receives an Error. Consequently, a handler that reads err.message or err.status does not need a null check for this case.
Async app.param and Error Middleware Are Covered
The router handles promises everywhere it calls your code, not only in route handlers. An async callback in app.param() that rejects becomes a 500. Similarly, an async error handler that itself rejects passes the new error to the next error handler, which falls through to the default one. In Express 4 both of those crashed the process.
Errors After the Response Is Sent
If a handler sends the response and then rejects, Express 5 still routes the error to your error middleware. By then the headers are on the wire, so the client already has its 200. A second probe shows a subtler version, where a middleware calls next() and its own promise rejects afterwards:
// next-then-reject.cjs: middleware calls next(), then its promise rejects
const express = require(process.argv[2]);
const app = express();
const audit = () => new Promise((_, reject) => setTimeout(() => reject(new Error('audit write failed')), 20));
app.use(async (req, res, next) => { next(); await audit(); });
app.get('/', (req, res) => res.send('ok'));
app.use((err, req, res, next) => { console.log(`error handler: ${err.message}, headersSent=${res.headersSent}`); next(err); });
const server = app.listen(0, async () => {
const r = await fetch(`http://127.0.0.1:${server.address().port}/`);
console.log(`client: status=${r.status} body=${await r.text()}`);
setTimeout(() => { console.log('process alive'); server.close(); }, 100);
});
NODE_ENV=production node next-then-reject.cjs express5
client: status=200 body=ok
error handler: audit write failed, headersSent=true
Error: audit write failed
process alive
So the error handler runs for a request that already succeeded. If that handler calls res.status(500).json(...) without checking res.headersSent, it throws ERR_HTTP_HEADERS_SENT from inside the error path. Every error handler on Express 5 needs that check as its first line.
Fire-and-Forget Work Still Crashes the Process
The floating promise row is the one that bites after the upgrade. A handler that starts an email send, an analytics call or a cache warm-up without await returns before that work fails. The router sees a resolved promise and moves on. When the background work rejects later, nothing holds it, and Node exits exactly as it did on Express 4. The same applies to a throw inside setTimeout, an event emitter listener or a callback-style API such as fs.readFile.
Background work therefore needs its own .catch(), written at the place where you start it:
// Start background work without blocking the response, and own its failure
import { logger } from './logger.js';
import { sendReceiptEmail } from './email.js';
import { ordersRepo } from './orders-repo.js';
app.post('/orders', async (req, res) => {
const order = await ordersRepo.create(req.body);
res.status(201).json(order);
sendReceiptEmail(order).catch((err) => {
logger.error({ err, orderId: order.id }, 'receipt email failed');
});
});
The .catch() is not decoration. Without it, one bounced email takes down the instance and every request it was serving. For anything that must not be lost, a job queue is the better home, and the Node.js cron jobs comparison covers when Bull is worth adding.
Should You Remove express-async-errors and asyncHandler?
Yes for express-async-errors, which you must remove because it breaks Express 5. For express-async-handler, removal is optional, since it still works but no longer does anything Express 5 does not. The two libraries fail in very different ways, and the first one fails before your app serves a single request.
First, npm install refuses the combination outright:
npm install express@5.3.0 express-async-errors@3.1.1 express-async-handler@1.2.0
npm error code ERESOLVE
npm error ERESOLVE unable to resolve dependency tree
npm error peer express@"^4.16.2" from express-async-errors@3.1.1
npm error Fix the upstream dependency conflict, or retry this command with --force or --legacy-peer-deps to accept an incorrect (and potentially broken) dependency resolution.
The tempting next step is --legacy-peer-deps. With that flag the install succeeds, and the app then fails at startup on the require:
// async-errors.cjs: the express-async-errors patch loaded on Express 5
const express = require('express');
require('express-async-errors');
console.log('express', require('express/package.json').version, 'patched without error');
Error: Cannot find module 'express/lib/router/layer'
Require stack:
- .\node_modules\express-async-errors\index.js
- .\async-errors.cjs
exit=1
The library works by monkey-patching express/lib/router/layer.js, a file that no longer exists because Express 5 moved routing into the separate router package. In other words, it patches internals rather than using a public API, which is why it cannot survive the upgrade. The fix is deleting the require line, since Express 5 now does what the patch did.
express-async-handler, by contrast, wraps each handler in a function that calls next(err) on rejection. That still works on 5.3.0:
express 5.3.0: status=503 body={"error":"db down"}
exit=0
You can remove it file by file whenever a route is touched anyway. There is no rush, and leaving it in place costs one extra function call per request.
The 5.3.0 Change to Default Error Logging
Express 5.3.0, released three days before this test, changed what the built-in error handler logs. Previously it printed err.stack only. Now it logs the whole error object, which includes Error.cause and any extra properties a library attached. This matters for async code in particular, because the idiomatic way to add context to an awaited failure is to wrap it with a cause:
// logging.cjs: node logging.cjs <express-package>
const express = require(process.argv[2]);
const app = express();
app.get('/orders/:id', async () => {
try {
await Promise.reject(Object.assign(new Error('connect ECONNREFUSED 10.0.4.12:5432'), { code: 'ECONNREFUSED' }));
} catch (cause) {
throw new Error('Failed to load order', { cause });
}
});
const server = app.listen(0, async () => {
const r = await fetch(`http://127.0.0.1:${server.address().port}/orders/42`);
console.log(`client: status=${r.status} content-type=${r.headers.get('content-type')}`);
console.log(`client body: ${(await r.text()).replace(/\s+/g, ' ').slice(0, 200)}`);
server.close();
});
for pkg in express52 express5; do NODE_ENV=production node logging.cjs $pkg; done
Here is the output, with paths shortened, node:internal frames removed, and the node_modules/router frames cut after the first one:
=== express52 (5.2.1), NODE_ENV=production
client: status=500 content-type=text/html; charset=utf-8
client body: <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>Error</title> </head> <body> <pre>Internal Server Error</pre> </body> </html>
Error: Failed to load order
at .\logging.cjs:9:11
=== express5 (5.3.0), NODE_ENV=production
client: status=500 content-type=text/html; charset=utf-8
client body: <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>Error</title> </head> <body> <pre>Internal Server Error</pre> </body> </html>
Error: Failed to load order
at .\logging.cjs:9:11
[cause]: Error: connect ECONNREFUSED 10.0.4.12:5432
at .\logging.cjs:7:40
at Layer.handleRequest (.\node_modules\router\lib\layer.js:152:17)
...
code: 'ECONNREFUSED'
}
}
On 5.2.1, the log says an order failed to load and nothing about why. On 5.3.0, the database address and the ECONNREFUSED code are right there. The client response is identical in both versions, so nothing extra leaks to the caller.
There is one leak to watch, though, and it is not new. With NODE_ENV unset, Express assumes development mode and sends the stack trace to the client:
=== express5 NODE_ENV unset
client: status=500 content-type=text/html; charset=utf-8
client body: <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <title>Error</title> </head> <body> <pre>Error: Failed to load order<br> at C:\Users\...
Container images that forget ENV NODE_ENV=production publish file paths to anyone who can trigger a 500. The API security checklist covers this along with the other headers worth checking before launch.
A Production Error Handler for Express 5
Most teams should not rely on the default handler at all, since it returns HTML to JSON clients. The handler below was run against three requests to confirm each branch. It returns JSON, keeps 5xx details out of the response, logs the full error with its cause, and handles the headers-sent case from the edge-case probe.
// src/errors.js
export class HttpError extends Error {
constructor(status, message, options) {
super(message, options);
this.name = 'HttpError';
this.status = status;
}
}
// Final error middleware: one place that decides status, body and logging
export function errorHandler(logger) {
// Express identifies error middleware by its four parameters, keep all four
return (err, req, res, next) => {
if (res.headersSent) {
// The response is already on the wire, let Express close the connection
return next(err);
}
const status = Number.isInteger(err?.status) && err.status >= 400 && err.status < 600 ? err.status : 500;
if (status >= 500) {
logger.error({ err, method: req.method, path: req.originalUrl }, 'request failed');
}
res.status(status).json({
error: status >= 500 ? 'Internal Server Error' : err.message,
});
};
}
// src/app.js
import express from 'express';
import { HttpError, errorHandler } from './errors.js';
import { logger } from './logger.js';
import { ordersRepo } from './orders-repo.js';
export const app = express();
app.use(express.json());
// No wrapper, no try/catch: a rejection from findById reaches errorHandler
app.get('/orders/:id', async (req, res) => {
const order = await ordersRepo.findById(req.params.id);
if (!order) throw new HttpError(404, `Order ${req.params.id} not found`);
res.json(order);
});
app.use(errorHandler(logger));
With a stub logger and a stub repository that returns an order for id 42, nothing for id 7, and a wrapped ECONNREFUSED for id 500, the three requests produced:
GET /orders/42 -> 200 {"id":"42","total":1999}
GET /orders/7 -> 404 {"error":"Order 7 not found"}
request failed /orders/500 Failed to load order | cause: connect ECONNREFUSED 10.0.4.12:5432
GET /orders/500 -> 500 {"error":"Internal Server Error"}
Two details are deliberate. The status check validates the range because Express 5’s res.status() throws a RangeError for anything outside 100 to 999, and a library error carrying status: 0 would otherwise crash the error handler. Also, res.status() in Express 5 only accepts integers, so a string status from an old library fails the Number.isInteger check and falls back to 500. For where this file belongs in a larger codebase, the Node.js project structure and error handling guide shows the layering.
How to Migrate Async Error Handling to Express 5
The order matters, because the library removal has to happen in the same commit as the version bump:
- Upgrade with
npm install express@5, and work through the other breaking changes in the Express 5 migration guide, mainly the new route path syntax - Delete
require('express-async-errors')or itsimportin the same commit, since the app will not start with it - Add the
res.headersSentcheck as the first line of every error middleware - Remove
try/catchblocks whose only statement incatchisnext(err) - Keep every
try/catchthat translates an error, retries, or adds acause - Search for promises that are started but not awaited, and give each one its own
.catch() - Unwrap
asyncHandler(...)calls gradually, as files are touched for other reasons - Confirm
NODE_ENV=productionis set in every image and deploy target
For step 6, a lint rule does most of the searching. The @typescript-eslint/no-floating-promises rule flags any promise that is neither awaited nor handled, which is exactly the floating-promise row in the results table. It needs type information, so it only works in TypeScript projects with typed linting enabled.
For step 4, the change is mostly deletion. Here is a typical Express 4 handler and its Express 5 equivalent:
// Express 4: the try/catch exists only to forward the error
app.get('/users/:id', async (req, res, next) => {
try {
const user = await usersRepo.findById(req.params.id);
res.json(user);
} catch (err) {
next(err);
}
});
// Express 5: the router forwards the rejection itself
app.get('/users/:id', async (req, res) => {
const user = await usersRepo.findById(req.params.id);
res.json(user);
});
If you are new to how await turns a rejection into a thrown error, the JavaScript async/await guide explains the mechanics this relies on.
Real-World Scenario: Removing the Wrappers From a Mid-Sized API
Consider a small backend team maintaining an Express 4 API with roughly 60 to 80 routes, written over several years by different people. Three styles of async error handling coexist. Older routes use try/catch with next(err), a later batch uses asyncHandler, and someone added express-async-errors to app.js after a production crash, so all three overlap.
The upgrade typically goes wrong at step one. The version bump fails npm ci with ERESOLVE, someone adds --legacy-peer-deps to .npmrc to unblock CI, and the next deploy crashes on startup with Cannot find module 'express/lib/router/layer'. Because the error names an Express internal, it looks like a broken Express install rather than a dead dependency. The actual fix is one deleted line, and the .npmrc change should be reverted with it.
The slower problem arrives a week later. With the wrappers gone, the team trusts the router to catch everything, and a route that fires an analytics event without await starts crashing pods whenever the analytics vendor has an outage. Express 4 would have crashed in the same place. The difference is that nobody was looking for it, because the upgrade felt like it had solved async errors. The trade-off is real: Express 5 removes a whole category of boilerplate, but the remaining category is now the only one, and it is easier to forget.
When to Use Express 5 Async Error Handling
- Your handlers are
asyncfunctions or return promises from a database client or HTTP library - You want to delete
asyncHandlerwrappers and forwardingtry/catchblocks rather than maintain them - Your Express 4 app currently crashes or hangs on unhandled rejections, and adding more wrappers feels like the wrong fix
- You rely on
Error.causeto add context, and want 5.3.0’s default logging to show the full chain - Starting a new Node service, where Express 5 is the line npm installs by default
When NOT to Rely on It Alone
- Background work is started without
await, since a rejection outside the returned promise still exits the process - Old callback-style libraries throw inside their callbacks, which no router can intercept
- Timers, event emitters or stream
errorevents carry the failure, as they never reach the handler’s promise express-async-errorsis still in the dependency tree and cannot be removed in the same release- Error handlers send a response without checking
res.headersSent, as late rejections now reach them
Common Mistakes With Express 5 Async Errors
- Adding
--legacy-peer-depsto silence ERESOLVE, which only moves theexpress-async-errorsfailure from install time to startup - Assuming the router catches everything, then shipping fire-and-forget calls with no
.catch() - Deleting
try/catchblocks that translated errors into 404s or 409s, so those cases turn into 500s - Keeping a global
unhandledRejectionhook that swallows errors, which turns crashes into hanging requests - Writing error middleware with three parameters, so Express treats it as normal middleware and never calls it with the error
- Leaving
NODE_ENVunset in containers, which sends stack traces to clients from the default handler - Testing only the happy path after the upgrade, when the behaviour change is entirely in the failure path
Next Steps for Your Express 5 Upgrade
Express 5 async error handling removes the most common reason Express apps crashed: an async handler that rejected with nobody listening. On 5.3.0, every rejection the router can see becomes a call to your error middleware, including ones from app.param() and from error handlers themselves. What it cannot see still kills the process, namely promises nobody awaited, timers and callbacks. Start today by deleting express-async-errors in the same commit as the version bump, then add the res.headersSent check to your error handler. After that, put the floating-promise lint rule in CI. If you are moving to Node 24 in the same release, the Node.js 24 breaking changes covers the runtime side, and the scalable Express project structure guide is a good next read for organizing routes once the wrappers are gone.