Express hasn't had a major release since 2014" was true for ten years and stopped being true in September 2024. Express 5.1 became the default on npm on 31 March 2025, so npm install express has given you version 5 for a while now. Express 4 went into maintenance the next day, and the Express team's target for its end of life is no sooner than 1 October 2026, according to the 5.1 release post.
So the useful questions have changed. Not "is Express dead?" but: what did 5 fix, what breaks when you upgrade — especially the parts that break without an error — and when is it still worth moving to something else?
The try/catch argument is out of date
The standard case against Express is that every async handler needs a try/catch and a next(err). In Express 5 it doesn't. A handler that returns a rejected promise has the rejection forwarded to your error middleware, as if you had called next(err) yourself. The migration guide covers it under "Rejected promises handled from middleware and handlers".
import express from "express"; const users = new Map([["1", { id: "1", name: "Ada" }]]);const findUser = async (id) => users.get(id) ?? null; const app = express(); app.get("/users/:id", async (req, res) => { const user = await findUser(req.params.id); if (!user) return res.status(404).json({ error: "not found" }); res.json(user);}); app.get("/boom", async () => { throw new Error("database is down");}); app.use((err, req, res, next) => { console.error(err); res.status(500).json({ error: "internal" });}); const server = app.listen(3000, (error) => { if (error) throw error; console.log(`listening on ${server.address().port}`);});$ curl -s localhost:3000/boom{"error":"internal"}No wrapper, no express-async-errors. You still need the four-argument error handler at the end; without it, Express's default handler answers instead.
The listen callback is not decoration either. It is one of the changes that fails quietly, covered below.
What breaks when you upgrade
Express 5 keeps the same shape of API, which is exactly why upgrades go wrong: the code still reads correctly. The changes fall into two groups.
Loud: removed signatures
Old signatures that Express 4 had deprecated are gone, and calling them throws: app.del(), req.param(), res.send(status), res.json(obj, status), res.redirect(url, status), res.sendfile() and the singular req.acceptsCharset() family. These are the easy ones. They fail at the first request that reaches them, and the Express team publishes codemods for them:
$ npx codemod@latest @expressjs/v5-migration-recipeLoud at startup: route strings
Express 5 moved to a newer path-to-regexp, and route strings follow new rules. The codemods don't cover these; the 5.1 post says so directly.
- app.get("*", serveSpa);+ app.get("/{*splat}", serveSpa);- app.get("/:file.:ext?", download);+ app.get("/:file{.:ext}", download);- app.get("/[discussion|page]/:slug", show);+ app.get(["/discussion/:slug", "/page/:slug"], show);A wildcard must now have a name, ? for optional segments is replaced by braces, and regular-expression characters in strings are not supported. Note the braces in /{*splat}: plain /*splat does not match the root path /, which matters for a single-page app fallback. The wildcard value also changes shape — req.params.splat is an array of segments, not a string.
Quiet: req.body is undefined when nothing parsed it
In Express 4, req.body defaulted to {}. In Express 5 it is undefined until a body parser has run. Middleware that destructures it on every request now throws on a GET, or on a POST with a content type no parser handled:
- const { email } = req.body;+ const { email } = req.body ?? {};express.urlencoded() also defaults to extended: false now. Forms that post nested fields need express.urlencoded({ extended: true }).
Quiet: req.query is a getter, and flatter
Two changes land on the same property. req.query is now a getter, so validation middleware that writes the parsed result back stops working. In ES modules and TypeScript output, which run in strict mode, assigning to a getter-only property throws a TypeError; property writes such as req.query.page = 1 don't stick either. Keep the validated value somewhere you own:
const result = schema.safeParse(req.query); if (!result.success) return res.status(400).json({ error: result.error.issues });- req.query = result.data;+ res.locals.query = result.data; next();The default query parser also changed from "extended" to "simple". A request for /orders?filter[status]=open used to give handlers { filter: { status: "open" } }; with the simple parser the key arrives flat, as "filter[status]". Nothing throws. Your filter is silently ignored and the endpoint returns everything. If your API relies on bracket syntax, say so explicitly:
app.set("query parser", "extended");The Express 5 changes that cost you a day are the ones that don't throw.
Quiet: dotfiles, listen errors and req.params
express.static() now defaults dotfiles to "ignore", and the check covers hidden directories in the path, not only hidden files. Anything you serve from .well-known returns 404.
app.listen() now passes server errors such as EADDRINUSE to its callback instead of throwing. A callback that ignores its argument logs "listening" while nothing is listening. That is why the example above checks error first.
req.params has a null prototype for string routes, so req.params.hasOwnProperty("ext") throws TypeError: req.params.hasOwnProperty is not a function. Use Object.hasOwn(req.params, "ext"). Unmatched optional parameters are now left out of req.params rather than set to undefined.
A few more are in the migration guide: res.status() throws on codes outside 100–999, res.clearCookie() ignores maxAge and expires, and .js files are served as text/javascript.
An upgrade that doesn't surprise you
- Check the runtime first. Express 5 needs Node.js 18 or later — a minimum, not a recommendation, so upgrade to a supported Node release at the same time.
- Run the codemod recipe, then read its diff rather than trusting it.
- Search for the quiet changes. The codemods can't fix what they can't see:
$ grep -rnE "app\.(get|use|all)\(['\"]\*|:[A-Za-z_]+\?|req\.query\s*=|req\.query\.[A-Za-z_]+\s*=|hasOwnProperty|dotfiles|\.listen\(" src/- Add tests for the behaviour that changes without an error: a request with bracketed query parameters, a
GETthrough every middleware that readsreq.body, and a request for a file under.well-known. - Upgrade your middleware along with Express. Anything that writes to
req.queryor assumesreq.bodyexists needs a version that supports Express 5.
What Express 5 still doesn't give you
The fair case against Express was never only about error handling, and most of it still stands.
Types are separate: TypeScript users rely on @types/express from DefinitelyTyped, and req.body is not inferred from anything. The 5.1 post lists improving the TypeScript experience as future work. Requests and responses are Node's own http objects, not the Web Request and Response that fetch-based runtimes use. Validation and OpenAPI generation are add-ons, not part of the framework.
Compare a route in Hono, which is built on those web standards:
import { serve } from "@hono/node-server";import { Hono } from "hono"; const app = new Hono(); app.get("/users/:id", (c) => c.json({ id: c.req.param("id") })); serve({ fetch: app.fetch, port: 3001 });app.fetch takes a Web Request and returns a Response, which is why the same app can run on Node, Bun, Deno or Cloudflare Workers with a different adapter.
On performance, the one-line benchmark tables that go around are hello-world routes on someone else's hardware. Measure your own endpoints under your own middleware before you decide on throughput alone. The Express project started a Performance Working Group in 2025, with support from the Sovereign Tech Fund, so the gap is at least being worked on.
When to move anyway
Stay on Express, and upgrade to 5, when you have a working application, a team that knows it, and middleware you depend on. Upgrading takes days. A rewrite takes months and brings its own new bugs.
Consider moving when the thing you need is exactly what Express leaves out:
- Fastify when you want schema-based validation and serialisation built in, and plugin encapsulation for a large codebase.
@fastify/expresslets you mount existing Express middleware while you move route by route. - Hono when you need the same code on Node and an edge or serverless runtime, or want Web
Request/Responsethroughout. - Elysia when you are on Bun and want request types inferred end to end.
For a new service, the choice is real rather than obvious, and "Express is abandoned" should not be part of it. Express 5 is the supported line; Express 4 is the one on its way out. If you are still on 4, the upgrade is the job for this quarter, and the quiet changes above are where to spend the testing time.