+1 (726) 227-3745

Express 4 to Express 5: The Migration Guide We Give Our Clients

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 4Express 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() becomes app.delete()
  • res.sendfile() becomes res.sendFile()
  • res.json(status, body) and res.send(status, body) become res.status(status).json(body)
  • res.redirect('back') and res.location('back') are removed; read req.get('Referrer') yourself
  • req.param(name) is removed; use req.params, req.query, or req.body explicitly
  • res.status() now throws on a non-integer or out-of-range code instead of sending garbage
  • app.router (the pseudo-middleware) is gone; mount routers with app.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() ignores maxAge and expires options.
  • express.urlencoded() defaults to extended: false.
  • app.listen() now propagates error events from the underlying server, so a port conflict is an error you can catch rather than a silent exit.
  • Brotli is supported in res.sendFile and express.static content 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

  1. Start the server. Invalid route patterns throw at registration time, so a clean boot eliminates the most common class of breakage immediately.
  2. 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.
  3. Grep for the removed methods listed above, including inside any shared middleware packages your organization publishes.
  4. 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.
  5. 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.