Skip to main content
Node.js · lesson 5 of 20
Node.js beginner Lesson 5 of 20

Middleware and the Request Pipeline

Write logging and error-handling middleware in Express, then watch registering the error handler before the routes silently break it.

By SkillByExample · Updated

nodejs express middleware

Express middleware is just a function with the signature (req, res, next), run in the order it’s registered with app.use. Error-handling middleware is the same idea with one extra parameter — and getting its position wrong fails silently.

A Logging Middleware and a Context-Setting One

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

const app = express();

function requestLogger(req, res, next) {
  const start = Date.now();
  res.on("finish", () => {
    console.log(`${req.method} ${req.originalUrl} -> ${res.statusCode} (${Date.now() - start}ms)`);
  });
  next();
}

function attachRequestTime(req, res, next) {
  req.requestTime = new Date().toISOString();
  next();
}

app.use(requestLogger);
app.use(attachRequestTime);
app.use(express.json());

const users = [{ id: 1, name: "Alice" }];

app.get("/users", (req, res) => {
  res.json({ requestedAt: req.requestTime, users });
});

app.get("/boom", (req, res) => {
  throw new Error("kaboom");
});

app.get("/missing-time", (req, res) => {
  // attachRequestTime already ran for every request, since it's registered
  // with app.use before any route — there's no route-specific opt-out here.
  res.json({ requestTime: req.requestTime });
});

// Error-handling middleware: four parameters, registered LAST, after every route.
app.use((err, req, res, next) => {
  console.error(`[error] ${req.method} ${req.originalUrl}:`, err.message);
  res.status(500).json({ error: "something broke" });
});

app.listen(3000, () => console.log("listening on http://localhost:3000"));
$ node server.js &
$ curl -s http://localhost:3000/users
{"requestedAt":"2026-10-28T09:16:07.325Z","users":[{"id":1,"name":"Alice"}]}
$ curl -s -i http://localhost:3000/boom
HTTP/1.1 500 Internal Server Error
X-Powered-By: Express
Content-Type: application/json; charset=utf-8
Content-Length: 27
ETag: W/"1b-N0reFvFPLkcfGJwmJEQFUZHd8tQ"
Date: Wed, 28 Oct 2026 09:16:08 GMT
Connection: keep-alive
Keep-Alive: timeout=5

{"error":"something broke"}
$ curl -s http://localhost:3000/missing-time
{"requestTime":"2026-10-28T09:16:09.168Z"}
listening on http://localhost:3000
GET /users -> 200 (8ms)
[error] GET /boom: kaboom
GET /boom -> 500 (1ms)
GET /missing-time -> 200 (1ms)

Three app.use calls ran, in order, before any route matched: requestLogger attaches a finish listener (which prints after the response is actually sent, which is why the log line for /boom appears after [error] ..., not before), attachRequestTime stamps every request, and express.json() parses bodies. /boom’s handler throws synchronously; Express catches it and forwards it to the error-handling middleware — the fourth parameter is what marks that function as an error handler instead of a normal one.

Registering the Error Handler in the Wrong Place

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

const app = express();
app.use(express.json());

// BUG: the error handler is registered before the routes, so it is never reached —
// Express only sends an error to the first 4-argument middleware that comes AFTER
// the handler that threw.
app.use((err, req, res, next) => {
  console.log("custom error handler ran");
  res.status(500).json({ error: "something broke" });
});

app.get("/boom", (req, res) => {
  throw new Error("kaboom");
});

app.listen(3000, () => console.log("listening on http://localhost:3000"));
$ node server-broken.js &
$ curl -s -i http://localhost:3000/boom
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 14:32:15 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: kaboom<br> &nbsp; &nbsp;at file:///root/node-api/server-broken.js:16:9<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 processParams (/root/node-api/node_modules/router/index.js:582:12)<br> &nbsp; &nbsp;at next (/root/node-api/node_modules/router/index.js:291:5)<br> &nbsp; &nbsp;at Layer.handleRequest (/root/node-api/node_modules/router/lib/layer.js:147:12)</pre>
</body>
</html>

Server-side, custom error handler ran never printed. Express’s own default error handler does write something on its way to building that HTML page — the same Error: kaboom stack shown in the response above, printed to stderr — but never the "custom error handler ran" line the lesson’s own handler was supposed to log. Registering an error handler before the routes it’s meant to protect doesn’t throw or warn; it just quietly never runs, and Express falls back to its own default error handler, which is why the response is an HTML stack trace page instead of the JSON {"error": "something broke"} the custom handler was supposed to send. The fix is purely about position: move app.use((err, req, res, next) => ...) to the bottom of the file, after every route, exactly as server.js does above.

Practice

1. Add a second logging middleware that only runs for /users routes, using app.use("/users", ...), and confirm it doesn’t run for /boom.

Solution
// server.js (added middleware)
app.use("/users", (req, res, next) => {
  console.log("users-only middleware ran");
  next();
});
$ curl -s http://localhost:3000/users
{"requestedAt":"2026-10-28T18:24:28.493Z","users":[{"id":1,"name":"Alice"}]}
$ curl -s http://localhost:3000/boom
{"error":"something broke"}
listening on http://localhost:3000
users-only middleware ran
GET /users -> 200 (9ms)
[error] GET /boom: kaboom
GET /boom -> 500 (1ms)

The path prefix passed as app.use’s first argument restricts which requests reach that middleware — "users-only middleware ran" appears once, for /users, and not at all for /boom.

2. Remove the next() call from attachRequestTime and hit /users. What happens?

Solution
// server.js (attachRequestTime missing next())
import express from "express";

const app = express();

function requestLogger(req, res, next) {
  const start = Date.now();
  res.on("finish", () => {
    console.log(`${req.method} ${req.originalUrl} -> ${res.statusCode} (${Date.now() - start}ms)`);
  });
  next();
}

function attachRequestTime(req, res, next) {
  req.requestTime = new Date().toISOString();
  // next() removed
}

app.use(requestLogger);
app.use(attachRequestTime);
app.use(express.json());

const users = [{ id: 1, name: "Alice" }];

app.get("/users", (req, res) => {
  res.json({ requestedAt: req.requestTime, users });
});

app.listen(3000, () => console.log("listening on http://localhost:3000"));
$ curl -s -o /dev/null -w 'http_code:%{http_code}\n' -m 3 http://localhost:3000/users
http_code:000
$ echo "curl exit: $?"
curl exit: 28

Nothing downstream of attachRequestTime ever runs — not express.json(), not the route, not a response. http_code:000 means curl never got any status line at all, and exit code 28 is curl’s own code for a timeout: the request just hangs until the client gives up, because nothing told Express to continue to the next middleware in the chain.

3. Add a middleware between attachRequestTime and the routes that logs req.requestTime, and confirm it sees the same value /users’s handler does.

Solution
// server.js (added middleware)
app.use((req, res, next) => {
  console.log(`requestTime at this point: ${req.requestTime}`);
  next();
});
$ curl -s http://localhost:3000/users
{"requestedAt":"2026-10-28T18:24:57.345Z","users":[{"id":1,"name":"Alice"}]}
listening on http://localhost:3000
requestTime at this point: 2026-10-28T18:24:57.345Z
GET /users -> 200 (5ms)

The logged value and the requestedAt in the response body match exactly — req is the same object passed through every middleware and the final handler, so a property set early is visible everywhere after it.

4. In server-broken.js, add a second app.use((err, req, res, next) => ...) after the route, in addition to the broken one before it. Which one responds?

Solution
// server-broken.js (added handler after the route)
import express from "express";

const app = express();
app.use(express.json());

app.use((err, req, res, next) => {
  console.log("custom error handler ran");
  res.status(500).json({ error: "something broke" });
});

app.get("/boom", (req, res) => {
  throw new Error("kaboom");
});

app.use((err, req, res, next) => {
  console.log("second error handler ran");
  res.status(500).json({ error: "handled" });
});

app.listen(3000, () => console.log("listening on http://localhost:3000"));
$ curl -s http://localhost:3000/boom
{"error":"handled"}
listening on http://localhost:3000
second error handler ran

The first error handler (before the route) is still never reached. The second one, registered after /boom, is the first error-handling middleware in the chain that comes after the code that threw, so it’s the one Express calls — proving the position relative to the throwing code is what matters, not the order among error handlers overall.

Frequently Asked Questions

What makes a function Express middleware instead of a route handler?
Nothing structural — both are (req, res, next) functions. The difference is registration: app.use(fn) runs fn for every request regardless of path (or every request under a path prefix), while app.get/post/etc. only run for a matching method and exact path pattern.
Why does an error-handling middleware need four parameters?
Express tells middleware apart by counting declared parameters. A function with exactly four — (err, req, res, next) — is registered as an error handler and is only invoked when something calls next(err), a synchronous handler throws, or (in Express 5) an async handler's returned promise rejects; a function with three or fewer is a normal middleware and never receives an error.
Why didn't my error-handling middleware run even though it's registered?
Order matters: Express only forwards an error to the first error-handling middleware that appears AFTER the code that threw, in registration order. An error handler registered before your routes is never reached by an error thrown inside them, and Express falls back to its own default error handler instead.
Does calling next() skip the rest of the middleware?
The opposite — next() is what lets the request continue to the next middleware or route in the chain. Forgetting to call it (and not sending a response either) leaves the request hanging with no response at all.
What's the difference between console.error and req.log in this lesson?
Nothing yet — this lesson's error handler still uses console.error. A later lesson replaces it with Pino, a structured logger that attaches request context (like an id) to every line automatically.