The Middleware Chain Pattern
Every Express application is, at its heart, a pipeline. A request enters at one end, flows through an ordered list of functions, and a response exits the other end. Each of those functions — a middleware — gets a chance to inspect, modify, short-circuit, or pass along the request. This is a textbook implementation of the chain-of-responsibility design pattern, and understanding it is the single most useful mental model for reasoning about Express. Once you see the chain, ordering, composition, and error handling all stop feeling like magic.
The chain of responsibility, made concrete
In the chain-of-responsibility pattern, a request is handed to a sequence of handlers; each handler either processes it or forwards it to the next link. Express implements this literally: every middleware has the signature (req, res, next), and calling next() advances to the following link. A middleware that does not call next() (and instead sends a response) ends the chain right there.
const express = require("express");
const app = express();
app.use((req, res, next) => {
console.log("1: logger");
next(); // pass control onward
});
app.use((req, res, next) => {
console.log("2: auth check");
next();
});
app.get("/", (req, res) => {
console.log("3: handler");
res.send("Hello"); // ends the chain — no next()
});
app.listen(3000);
Output:
$ curl -s http://localhost:3000/
Hello
# server log:
1: logger
2: auth check
3: handler
The shared req and res objects are the request’s “context” travelling down the chain. Any middleware can attach data — req.user, req.requestId — that later links read. This is how cross-cutting concerns communicate without tight coupling.
Composing cross-cutting concerns
The real power of the chain is composition: behaviors that apply across many routes (logging, authentication, validation, rate limiting) become standalone, reusable functions you stack in whatever combination a route needs. Each concern lives in one place and knows nothing about the others.
// middleware/requestId.js
const { randomUUID } = require("crypto");
module.exports = (req, res, next) => {
req.id = randomUUID();
res.setHeader("X-Request-Id", req.id);
next();
};
// middleware/requireAuth.js
module.exports = (req, res, next) => {
if (!req.headers.authorization) {
return res.status(401).json({ error: "Unauthorized" });
}
req.user = { id: 42 }; // decoded from a real token in practice
next();
};
You then compose them globally or per-route. Express accepts an array of middleware, which makes a route’s pipeline explicit and self-documenting:
const requestId = require("./middleware/requestId");
const requireAuth = require("./middleware/requireAuth");
app.use(requestId); // applies to every request
// per-route composition — order reads top to bottom
app.get("/profile", requireAuth, (req, res) => {
res.json({ id: req.user.id, requestId: req.id });
});
Tip: Because middleware are just functions, you can build higher-order factories that return them — e.g.
rateLimit({ max: 100 }). This is how libraries likeexpress-rate-limitandmulterconfigure behavior while still fitting the(req, res, next)contract.
Ordering for predictable behavior
Middleware run in the exact order they are registered. This is not a detail — it is the contract. Put body parsing before handlers that read req.body, authentication before authorization, and the error handler last. Get the order wrong and the chain still runs, but it produces subtly broken behavior.
| Stage | Typical middleware | Why it goes here |
|---|---|---|
| Early | request ID, logging, helmet | Must observe/secure every request |
| Parsing | express.json(), cookie-parser | Later links depend on req.body/req.cookies |
| Auth | requireAuth, RBAC checks | Reject unauthorized requests before doing work |
| Route | controllers / handlers | The actual business logic |
| Terminal | 404 handler, error handler | Catch what nothing else handled |
A route-level chain also supports branching: a guard middleware can short-circuit with a response, skipping every link downstream.
function adminOnly(req, res, next) {
if (req.user?.role !== "admin") {
return res.status(403).json({ error: "Forbidden" }); // chain stops here
}
next(); // only admins reach the handler
}
app.delete("/users/:id", requireAuth, adminOnly, (req, res) => {
res.json({ deleted: req.params.id });
});
The error-handling sub-chain
Express keeps a parallel chain for errors. A middleware with four parameters — (err, req, res, next) — is treated as an error handler and is skipped during normal flow. Calling next(err) with an argument jumps past every remaining normal middleware straight to the next error handler. This keeps the happy path clean.
app.get("/boom", async (req, res, next) => {
try {
throw new Error("kaboom");
} catch (err) {
next(err); // hand off to the error chain
}
});
// registered LAST, after all routes
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message, requestId: req.id });
});
Note: In Express 5.x, errors thrown (or rejected promises) from an
asynchandler are forwarded to the error chain automatically, so the explicittry/catchabove is optional. On Express 4.x you must callnext(err)yourself or wrap handlers.
Best practices
- Treat each middleware as a single responsibility — one concern per function makes the chain easy to recompose.
- Register global, app-wide concerns with
app.use()early; scope route-specific guards to the route via the argument list. - Always either call
next()or send a response — doing neither hangs the request forever. - Keep parsing and auth middleware ahead of any handler that depends on their output; order is the contract.
- Use
next(err)(neverthrowin callbacks) to route failures into the error chain, and register the error handler last. - Build configurable middleware as factories that return
(req, res, next), keeping them reusable across routes and projects.