Skip to content
Express.js ex patterns 4 min read

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 like express-rate-limit and multer configure 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.

StageTypical middlewareWhy it goes here
Earlyrequest ID, logging, helmetMust observe/secure every request
Parsingexpress.json(), cookie-parserLater links depend on req.body/req.cookies
AuthrequireAuth, RBAC checksReject unauthorized requests before doing work
Routecontrollers / handlersThe actual business logic
Terminal404 handler, error handlerCatch 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 async handler are forwarded to the error chain automatically, so the explicit try/catch above is optional. On Express 4.x you must call next(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) (never throw in 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.
Last updated June 14, 2026
Was this helpful?