Skip to main content
Node.js · lesson 8 of 20
Node.js intermediate Lesson 8 of 20

Async Route Handlers and Errors in Express 5

Watch an Express 4 async handler crash the whole process on an unhandled rejection, then see Express 5 forward the same throw to the error handler with no wrapper.

By SkillByExample · Published

nodejs express async errors

Most still-published Express tutorials show a wrapper around every async route handler. On Express 5 it’s dead code — but to see why it existed, this lesson reproduces the crash it was written to prevent, on Express 4.22.3.

Express 4: An Unhandled Rejection Kills the Process

// server.js
import express from "express";

const app = express();

async function findUser(id) {
  if (id !== "1") throw new Error(`user ${id} not found`);
  return { id: 1, name: "Alice" };
}

app.get("/users/:id", async (req, res) => {
  const user = await findUser(req.params.id);
  res.json(user);
});

app.listen(3000, () => console.log("listening on http://localhost:3000"));
$ npm install express@4.22.3
$ node server.js &
$ curl -s http://localhost:3000/users/1
{"id":1,"name":"Alice"}
$ curl -s -S http://localhost:3000/users/99
curl: (52) Empty reply from server
$ echo "curl exit: $?"
curl exit: 52

The server’s own output shows why the connection just dies instead of returning a response:

listening on http://localhost:3000
file:///root/node-api/server.js:7
  if (id !== "1") throw new Error(`user ${id} not found`);
                        ^

Error: user 99 not found
    at findUser (file:///root/node-api/server.js:7:25)
    at file:///root/node-api/server.js:12:22
    at Layer.handle [as handle_request] (/root/node-api/node_modules/express/lib/router/layer.js:95:5)
    at next (/root/node-api/node_modules/express/lib/router/route.js:149:13)
    at Route.dispatch (/root/node-api/node_modules/express/lib/router/route.js:119:3)
    at Layer.handle [as handle_request] (/root/node-api/node_modules/express/lib/router/layer.js:95:5)
    at /root/node-api/node_modules/express/lib/router/index.js:284:15
    at param (/root/node-api/node_modules/express/lib/router/index.js:365:14)
    at param (/root/node-api/node_modules/express/lib/router/index.js:376:14)
    at router.process_params (/root/node-api/node_modules/express/lib/router/index.js:421:3)

Node.js v26.12.1

findUser throws inside an async function, which turns it into a rejected promise. The route handler never awaits it into a try/catch, and Express 4 does nothing with the handler’s returned promise at all — it doesn’t call .catch() on it. With no handler attached anywhere, Node’s unhandled-rejection behavior (default since Node 15) takes over and terminates the whole process mid-request — the connection to the in-flight /users/99 request is torn down with nothing written to it, which is why curl reports (52) Empty reply from server rather than any HTTP status at all. Every other in-flight request on the same process would have been dropped the same way.

The Old Fix: Wrap Every Async Handler

// server-wrapped.js
import express from "express";

const app = express();

async function findUser(id) {
  if (id !== "1") throw new Error(`user ${id} not found`);
  return { id: 1, name: "Alice" };
}

// The old Express 4 fix: wrap every async handler so a rejection reaches next(err)
// instead of becoming an unhandled rejection.
function asyncHandler(fn) {
  return (req, res, next) => fn(req, res, next).catch(next);
}

app.get(
  "/users/:id",
  asyncHandler(async (req, res) => {
    const user = await findUser(req.params.id);
    res.json(user);
  })
);

app.use((err, req, res, next) => {
  res.status(404).json({ error: err.message });
});

app.listen(3000, () => console.log("listening on http://localhost:3000"));
$ node server-wrapped.js &
$ curl -s -i http://localhost:3000/users/99
HTTP/1.1 404 Not Found
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 29
ETag: W/"1d-Qiw1G6lPhNjZkT5WvRGrOrxQvgA"
Date: Wed, 28 Oct 2026 09:21:43 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"error":"user 99 not found"}

asyncHandler calls the real handler, and if the promise it returns rejects, .catch(next) forwards that rejection to Express’s normal next(err) path — the same path a synchronous throw already used. The server stays up, and the error-handling middleware turns it into a clean 404.

Express 5: The Wrapper Is Unnecessary

// server-express5.js
import express from "express";

const app = express();

async function findUser(id) {
  if (id !== "1") throw new Error(`user ${id} not found`);
  return { id: 1, name: "Alice" };
}

app.get("/users/:id", async (req, res) => {
  const user = await findUser(req.params.id);
  res.json(user);
});

app.use((err, req, res, next) => {
  res.status(404).json({ error: err.message });
});

app.listen(3000, () => console.log("listening on http://localhost:3000"));
$ npm install express@5.3.0
$ node server-express5.js &
$ curl -s http://localhost:3000/users/1
{"id":1,"name":"Alice"}
$ curl -s -i http://localhost:3000/users/99
HTTP/1.1 404 Not Found
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 29
ETag: W/"1d-Qiw1G6lPhNjZkT5WvRGrOrxQvgA"
Date: Wed, 28 Oct 2026 09:22:13 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"error":"user 99 not found"}

Identical route handler, no asyncHandler, same 404 response — Express 5 forwards a rejected promise from any route or middleware handler to the error chain by itself. The crash from the first example and the wrapper from the second are both Express-4-specific; on Express 5 neither exists.

Practice

1. Add a second async route, GET /slow/:ms, that awaits setTimeout for ms milliseconds and then throws if ms is negative, on the Express 5 server — confirm a negative value 404s instead of crashing.

Solution
// server-express5.js (added route)
app.get("/slow/:ms", async (req, res) => {
  const ms = Number(req.params.ms);
  await new Promise((resolve) => setTimeout(resolve, Math.abs(ms)));
  if (ms < 0) throw new Error(`negative delay: ${ms}`);
  res.json({ waited: ms });
});
$ curl -s http://localhost:3000/slow/50
{"waited":50}
$ curl -s http://localhost:3000/slow/-10
{"error":"negative delay: -10"}

The throw happens after an await, inside a microtask queued well after the synchronous part of the handler returned — Express 5 still catches it, because it attaches its own .catch to the whole async function’s returned promise, not just to code that runs before the first await.

2. On the Express 4 server, wrap just the findUser call in a try/catch inside the route (no asyncHandler), and send the error to res directly. Does this avoid the crash?

Solution
// server.js (local try/catch, no asyncHandler)
import express from "express";

const app = express();

async function findUser(id) {
  if (id !== "1") throw new Error(`user ${id} not found`);
  return { id: 1, name: "Alice" };
}

app.get("/users/:id", async (req, res) => {
  try {
    const user = await findUser(req.params.id);
    res.json(user);
  } catch (err) {
    res.status(404).json({ error: err.message });
  }
});

app.listen(3000, () => console.log("listening on http://localhost:3000"));
$ curl -s -i http://localhost:3000/users/99
HTTP/1.1 404 Not Found
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 29
ETag: W/"1d-Qiw1G6lPhNjZkT5WvRGrOrxQvgA"
Date: Wed, 28 Oct 2026 10:40:22 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"error":"user 99 not found"}

Yes — a local try/catch around the await prevents the rejection from ever escaping the handler at all, regardless of Express’s version or any wrapper. It’s Express-4-safe, but it means repeating this pattern in every single async handler; asyncHandler (or upgrading to Express 5) centralizes it instead.

3. Remove the error-handling middleware entirely from server-express5.js and hit /users/99 again. What response comes back?

Solution
// server-express5-noerr.js
import express from "express";

const app = express();

async function findUser(id) {
  if (id !== "1") throw new Error(`user ${id} not found`);
  return { id: 1, name: "Alice" };
}

app.get("/users/:id", async (req, res) => {
  const user = await findUser(req.params.id);
  res.json(user);
});

app.listen(3000, () => console.log("listening on http://localhost:3000"));
$ curl -s -i http://localhost:3000/users/99
HTTP/1.1 500 Internal Server Error
X-Powered-By: Express
Content-Security-Policy: default-src 'none'
X-Content-Type-Options: nosniff
Content-Type: text/html; charset=utf-8
Content-Length: 983
Date: Wed, 28 Oct 2026 10:41:49 GMT
Connection: keep-alive
Keep-Alive: timeout=5

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>Error: user 99 not found<br> &nbsp; &nbsp;at findUser (file:///root/node-api/server-express5-noerr.js:7:25)<br> &nbsp; &nbsp;at file:///root/node-api/server-express5-noerr.js:12:22<br> &nbsp; &nbsp;at Layer.handleRequest (/root/node-api/node_modules/router/lib/layer.js:152:17)<br> &nbsp; &nbsp;at next (/root/node-api/node_modules/router/lib/route.js:157:13)<br> &nbsp; &nbsp;at Route.dispatch (/root/node-api/node_modules/router/lib/route.js:117:3)<br> &nbsp; &nbsp;at handle (/root/node-api/node_modules/router/index.js:435:11)<br> &nbsp; &nbsp;at Layer.handleRequest (/root/node-api/node_modules/router/lib/layer.js:152:17)<br> &nbsp; &nbsp;at /root/node-api/node_modules/router/index.js:295:15<br> &nbsp; &nbsp;at param (/root/node-api/node_modules/router/index.js:600:14)<br> &nbsp; &nbsp;at param (/root/node-api/node_modules/router/index.js:610:14)</pre>
</body>
</html>

With no custom error handler, Express 5 still forwards the rejection — just to its own default error handler, the same HTML stack-trace page seen in earlier lessons for a synchronous throw. The process doesn’t crash either way; only the response shape changes.

4. On the Express 4 crash server, add a top-level process.on("unhandledRejection", ...) handler that just logs instead of letting the default behavior terminate the process. Does the server survive the same request now?

Solution
// server.js (added process-level handler)
import express from "express";

const app = express();

async function findUser(id) {
  if (id !== "1") throw new Error(`user ${id} not found`);
  return { id: 1, name: "Alice" };
}

app.get("/users/:id", async (req, res) => {
  const user = await findUser(req.params.id);
  res.json(user);
});

process.on("unhandledRejection", (err) => {
  console.error("unhandled rejection (process survives):", err.message);
});

app.listen(3000, () => console.log("listening on http://localhost:3000"));
$ curl -s -m 5 http://localhost:3000/users/99
$ echo "curl exit: $?"
curl exit: 28
listening on http://localhost:3000
unhandled rejection (process survives): user 99 not found

The process stays up — but the request itself still never gets a response (curl’s -m 5 times it out), because nothing ever called res.json or res.status. A process-level handler only stops the crash; it doesn’t give the stuck request a response, which is exactly the gap asyncHandler or Express 5 actually closes.

Frequently Asked Questions

Why does an unhandled promise rejection crash the whole Node process?
Since Node 15, an unhandled promise rejection terminates the process by default (the old behavior just printed a deprecation warning and kept running). An Express 4 route that returns a rejected promise with nothing attached to catch it triggers exactly this — one bad request can take the whole server down.
What exactly changed between Express 4 and Express 5 for async handlers?
Express 5 wraps every route and middleware handler so that if it returns a rejected promise, Express automatically calls next(err) with it — forwarding to your error-handling middleware. Express 4 never did this; a thrown error inside a non-async handler was still caught, but a rejected promise from an async function was not.
Do I still need a try/catch inside an async Express 5 handler?
Only if you want to handle a specific error differently from your global error handler — for example, to return a more specific status code. For everything else, letting it throw and reach the error-handling middleware is the simplest correct behavior in Express 5.
Is the old asyncHandler wrapper pattern still something I'll see in real code?
Yes — it's in a huge number of still-published Express tutorials and in production codebases that haven't migrated, because it was the standard fix for Express 4. It isn't wrong on Express 5, it's just unnecessary there; Express 5 already does what the wrapper used to do.
What Express versions does this lesson compare?
Express 4.22.3 (the crash and its wrapper fix) against Express 5.3.0 (where neither is needed), both on Node 26.12.1.