Skip to content
Express.js ex getting-started 5 min read

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.

AspectExpress 4Express 5
Async error handlingManual try/catch + next(err)Rejected promises auto-forwarded
Path matchingOld path-to-regexp, loose patternsNew 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 supportNode 0.10+Node 18+
Wildcard routesBare *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 patternExpress 5 equivalentReason
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.

  1. Bump Node.js to version 18 or newer; Express 5 will not run on older runtimes.
  2. Install Express 5: npm install express@5.
  3. Search for removed methodsapp.del, res.json(obj, status), res.send(<number>), res.sendfile, req.param(.
  4. Fix wildcard routes — replace bare * with /*splat and convert optional :param? to brace syntax.
  5. Remove redundant try/catch from async handlers (optional, but it is the payoff of upgrading).
  6. Add one error-handling middleware as the last app.use() if you do not already have one — it now catches async rejections too.
  7. 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/catch from 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.
Last updated June 14, 2026
Was this helpful?