Express 5 became stable in September 2024 and has been what npm install express gives you since March 2025. It is a conservative major: the routing engine and the middleware model are the same, and most Express 4 applications run after a few hours of targeted changes. This is the guide we hand to clients before we start, so the upgrade is a checklist rather than a surprise.
Prerequisites
Express 5 requires Node.js 18 or later. If you are on Node 18 or 20, both of which are now end of life, upgrade to Node 24 LTS first; it is a prerequisite for the rest of your dependency tree anyway.
npm install express@5
npm ls express # confirm nothing is pinning a nested express@4
What actually breaks
1. Rejected promises in async handlers are handled for you
This is the headline change. In Express 4, a rejected promise inside an async route handler was silently swallowed unless you wrapped every handler:
// Express 4: the wrapper everyone wrote (or pulled in as express-async-handler)
const wrap = (fn) => (req, res, next) => fn(req, res, next).catch(next);
app.get('/api/tasks/:id', wrap(async (req, res) => {
const task = await Task.findById(req.params.id);
if (!task) throw new NotFound();
res.json(task);
}));
In Express 5, a rejected promise from a handler or middleware is passed to next(err) automatically:
// Express 5
app.get('/api/tasks/:id', async (req, res) => {
const task = await Task.findById(req.params.id);
if (!task) throw new NotFound();
res.json(task);
});
Delete the wrappers. Keep your error-handling middleware ((err, req, res, next) => {...}) exactly as it is; it is where those rejections now land.
2. Path-matching syntax changed (path-to-regexp 8)
Express 5 uses a newer version of path-to-regexp with a stricter syntax. The patterns that fail loudly at startup are:
| Express 4 | Express 5 |
|---|---|
app.get('/files/*', ...) | app.get('/files/*splat', ...) (named wildcard) or '/files/{*splat}' to allow the empty case |
app.get('/users/:id?', ...) | app.get('/users{/:id}', ...) (optional segment in braces) |
app.get('/a/:id(\\d+)', ...) | Regex constraints in parameters are removed; validate in the handler |
app.get(/\/report-.*/, ...) | Regular expression routes still work |
The wildcard parameter is now available as req.params.splat (or whatever you named it). Most codebases have a handful of these, usually around static file and catch-all SPA routes, which is where the Angular index fallback lives:
// Express 5: serve the Angular app for any non-API route
app.get('/{*splat}', (req, res) => {
res.sendFile(path.join(__dirname, 'dist/browser/index.html'));
});
3. req.query is a getter
req.query is now a read-only getter and, by default, uses the "simple" parser (querystring) rather than qs. Nested objects like ?filter[status]=open are no longer parsed into objects unless you opt back in:
app.set('query parser', 'extended'); // restores Express 4 behavior
Before restoring it, read our post on NoSQL injection; the simple parser is one of the reasons Express 5 is safer out of the box.
4. Removed and renamed methods
These were deprecated for years and are gone now:
app.del()becomesapp.delete()res.sendfile()becomesres.sendFile()res.json(status, body)andres.send(status, body)becomeres.status(status).json(body)res.redirect('back')andres.location('back')are removed; readreq.get('Referrer')yourselfreq.param(name)is removed; usereq.params,req.query, orreq.bodyexplicitlyres.status()now throws on a non-integer or out-of-range code instead of sending garbageapp.router(the pseudo-middleware) is gone; mount routers withapp.use()
5. req.body is undefined until a parser runs
In Express 4, req.body defaulted to {}. In Express 5 it is undefined unless express.json() or express.urlencoded() (or another body parser) has populated it. Code that destructures const { name } = req.body on a route without a parser will now throw, which is arguably the correct behavior, but it surfaces during the upgrade.
6. Smaller changes worth a grep
res.clearCookie()ignoresmaxAgeandexpiresoptions.express.urlencoded()defaults toextended: false.app.listen()now propagateserrorevents from the underlying server, so a port conflict is an error you can catch rather than a silent exit.- Brotli is supported in
res.sendFileandexpress.staticcontent negotiation.
Use the codemod
The Express team publishes a codemod that handles most of the mechanical renames:
npx @expressjs/codemod upgrade
Run it, review the diff, then deal with the route-pattern changes by hand, because those depend on intent. The official migration guide has the full list if you want to audit the codemod's work.
Testing the upgrade
- Start the server. Invalid route patterns throw at registration time, so a clean boot eliminates the most common class of breakage immediately.
- Run the integration suite. If you do not have one, this is the moment to add Supertest tests for every route; the upgrade is the excuse you needed.
- Grep for the removed methods listed above, including inside any shared middleware packages your organization publishes.
- Check error responses. With async errors now reaching your error middleware, you may discover rejections that were previously swallowed and surfaced as timeouts. Those are bugs you had before; now you can see them.
- Load test one representative endpoint with k6 or Artillery to confirm no regression, then deploy behind a canary.
Why bother
Beyond staying on a supported version, the practical payoff is the deletion of half your boilerplate. Every wrap(), every try { ... } catch (err) { next(err) }, and every express-async-errors import can go. The codebase gets shorter and the failure modes get honest. For a typical MEAN API of a few dozen routes we budget one to two engineer-days, including tests. If you would rather hand it to us, get in touch.