Express 4 vs Express 5
Express 4 was the de facto standard for nearly a decade, and in 2024 Express 5 became the version you get from a plain npm install express. The good news is that Express 5 is a modernization, not a rewrite — the routing, middleware, and request/response model you already know are unchanged. The breaking changes are deliberately small and mostly affect rarely used or already-deprecated APIs. This page walks through what actually changed: automatic promise-rejection handling, removed methods, stricter path matching, and a concrete migration checklist.
Why Express 5 exists
Express 4 accumulated a long tail of deprecated aliases and loose behaviors that were kept for backward compatibility. It also relied on an old version of the path-to-regexp route parser, which had known security and predictability issues. Express 5 cleans up that surface: it drops dead APIs, upgrades the path matcher, raises the minimum Node.js version to 18, and — most importantly for everyday code — forwards rejected promises from your handlers to your error middleware automatically.
| Aspect | Express 4 | Express 5 |
|---|---|---|
| Async error handling | Manual try/catch + next(err) | Rejected promises auto-forwarded |
| Path matching | Old path-to-regexp, loose patterns | New parser, stricter patterns |
app.del() | Available (deprecated) | Removed — use app.delete() |
res.json(obj, status) | Available (deprecated) | Removed — use res.status(code).json(obj) |
| Node.js support | Node 0.10+ | Node 18+ |
| Wildcard routes | Bare * | Named splat, e.g. *splat |
Promise rejection handling in handlers
This is the change you will feel most. In Express 4, a route handler that returns a rejected promise (typically an unawaited await that throws) does not reach your error handler. The rejection is unhandled, the request hangs, and the client eventually times out. You had to wrap every async handler in try/catch.
// Express 4 — must catch and forward manually
app.get("/users/:id", async (req, res, next) => {
try {
const user = await db.findUser(req.params.id);
if (!user) return res.status(404).json({ error: "Not found" });
res.json(user);
} catch (err) {
next(err); // without this, the request hangs
}
});
In Express 5, if an async handler throws or returns a rejected promise, Express catches it and passes the error to your error-handling middleware for you. The try/catch boilerplate disappears.
// Express 5 — rejections are forwarded automatically
app.get("/users/:id", async (req, res) => {
const user = await db.findUser(req.params.id);
if (!user) return res.status(404).json({ error: "Not found" });
res.json(user);
});
// Last middleware in the stack catches everything
app.use((err, req, res, next) => {
console.error(err);
res.status(500).json({ error: "Internal Server Error" });
});
Output: a request for a failing lookup now returns a clean response instead of hanging.
HTTP/1.1 500 Internal Server Error
Content-Type: application/json; charset=utf-8
{"error":"Internal Server Error"}
Note: This auto-forwarding applies only to middleware and route handlers that return a promise. Errors thrown asynchronously inside a
setTimeout, an event emitter, or a non-awaited callback are still your responsibility — wrap those manually or let them crash the process.
Removed and renamed methods
Several deprecated APIs were finally removed. The replacements have existed for years, so these are mechanical fixes.
// Removed: app.del() → use app.delete()
app.del("/items/:id", handler); // ❌ Express 5
app.delete("/items/:id", handler); // ✅
// Removed: res.json(obj, status) → use res.status(code).json(obj)
res.json({ ok: true }, 201); // ❌ Express 5
res.status(201).json({ ok: true }); // ✅
// Removed: res.send(status) for numeric status → use res.sendStatus(code)
res.send(404); // ❌ Express 5
res.sendStatus(404); // ✅
A few request/response properties also changed. req.param(name) (the singular lookup helper) is gone — use req.params, req.body, or req.query directly. res.sendfile() (lowercase) was removed in favor of res.sendFile(). And app.param(fn) with a function-array form was dropped.
Path-matching changes
Express 5 ships a newer path-to-regexp, which makes route patterns stricter and safer but breaks a handful of loose Express 4 patterns.
| Express 4 pattern | Express 5 equivalent | Reason |
|---|---|---|
app.get("*", ...) | app.get("/*splat", ...) | Wildcards must be named |
"/:file(.*)" | "/:file(*splat)" | Regex capture syntax tightened |
"/users/:id?" | "/users{/:id}" | Optional params use new brace syntax |
// Express 4 catch-all
app.use("*", (req, res) => res.status(404).json({ error: "Not found" }));
// Express 5 — named splat parameter
app.use("/*splat", (req, res) => res.status(404).json({ error: "Not found" }));
The headline rule: a bare * is no longer a valid path. Anonymous wildcards must become named splats (*name), and optional segments now use {} braces. Most apps only have one or two catch-all routes, so this is usually a small edit.
Migration checklist
Work through these in order — most apps clear the list in well under an hour.
- Bump Node.js to version 18 or newer; Express 5 will not run on older runtimes.
- Install Express 5:
npm install express@5. - Search for removed methods —
app.del,res.json(obj, status),res.send(<number>),res.sendfile,req.param(. - Fix wildcard routes — replace bare
*with/*splatand convert optional:param?to brace syntax. - Remove redundant
try/catchfrom async handlers (optional, but it is the payoff of upgrading). - Add one error-handling middleware as the last
app.use()if you do not already have one — it now catches async rejections too. - Run your test suite and exercise catch-all and parameterized routes manually.
npm install express@5
node --version # must print v18.x or higher
Best Practices
- Start every new project on Express 5 so async error handling works out of the box.
- Pin the major version in
package.json("express": "^5.0.0") to avoid surprise upgrades. - Keep one terminal error-handling middleware as the last middleware — it is now your safety net for promise rejections.
- Audit catch-all routes first when migrating; path-matching changes are the most common breakage.
- Drop
try/catchfrom async routes only after confirming you have an error handler registered. - Still wrap errors thrown outside the promise chain (timers, event emitters) manually — auto-forwarding does not cover them.
- Run the full test suite against Node 18+ before deploying, since the runtime floor moved.