Node.js · lesson 8 of 20
Beginner
6Intermediate
10- 7 Structured Logging with Pino
- 8 Async Route Handlers and Errors in Express 5
- 9 Testing an HTTP API with node:test and fetch
- 10 The Same API in Fastify
- 11 JWT Auth Middleware
- 12 Graceful Shutdown and Health Checks
- 13 File Uploads with multer
- 14 A WebSocket Server with ws
- 15 OpenAPI Docs from Fastify Schemas
- 16 PostgreSQL with pg: Pools and Transactions
Advanced
4Also part of Node.js
10- + Files, Streams, and the Command Line in Node.js from JavaScript
- + JavaScript Event Loop Explained from JavaScript
- + JavaScript Modules: ESM and CommonJS from JavaScript
- + Introduction to Docker from Docker
- + Managing Docker Containers from Docker
- + Multi-Stage Builds from Docker
- + Docker Compose from Docker
- + Node.js with TypeScript from TypeScript
- + Unsafe File Uploads: secure_filename, Allowlists and Size Limits from Web Security (OWASP Top 10, fix-side)
- + Decorators in TypeScript from TypeScript
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
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> at findUser (file:///root/node-api/server-express5-noerr.js:7:25)<br> at file:///root/node-api/server-express5-noerr.js:12:22<br> at Layer.handleRequest (/root/node-api/node_modules/router/lib/layer.js:152:17)<br> at next (/root/node-api/node_modules/router/lib/route.js:157:13)<br> at Route.dispatch (/root/node-api/node_modules/router/lib/route.js:117:3)<br> at handle (/root/node-api/node_modules/router/index.js:435:11)<br> at Layer.handleRequest (/root/node-api/node_modules/router/lib/layer.js:152:17)<br> at /root/node-api/node_modules/router/index.js:295:15<br> at param (/root/node-api/node_modules/router/index.js:600:14)<br> 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.