The Middleware Pattern
The middleware pattern models a request as it flows through an ordered pipeline of small, single-purpose functions. Each function inspects or mutates a shared context, then either hands control to the next function via next() or short-circuits the chain by producing a response. It is a pragmatic spin on the classic Chain of Responsibility pattern, and it underpins virtually every Node.js web framework — Express, Koa, Connect, and Fastify all expose some flavor of it. Understanding the mechanics lets you compose cross-cutting concerns (logging, auth, parsing, error handling) without tangling them into your business logic.
How the pipeline works
A middleware function has a fixed signature. In Express it is (req, res, next); the function does its work and calls next() to advance the chain. If it never calls next() and never sends a response, the request simply hangs — a common beginner bug. If it calls next(err) with an argument, Express skips straight to error-handling middleware.
The key insight is that registration order is execution order. Middleware registered earlier runs first on the way in, which makes the sequence of app.use(...) calls a declarative description of your request lifecycle.
import express from "express";
const app = express();
// 1. Logging runs first for every request
app.use((req, res, next) => {
req.startedAt = Date.now();
console.log(`-> ${req.method} ${req.url}`);
next();
});
// 2. Body parsing for JSON payloads
app.use(express.json());
// 3. A route handler is just terminal middleware
app.get("/health", (req, res) => {
const ms = Date.now() - req.startedAt;
res.json({ ok: true, ms });
});
app.listen(3000, () => console.log("listening on :3000"));
Output:
listening on :3000
-> GET /health
Building a custom middleware engine
The pattern is small enough to implement from scratch, which is the best way to understand it. The engine keeps an array of middleware and a dispatch function that recursively invokes the next one in line. Each middleware receives a shared context object and a next callback that returns a promise, so the whole chain is async-aware.
class Pipeline {
#stack = [];
use(fn) {
this.#stack.push(fn);
return this; // allow chaining
}
async run(context) {
let index = -1;
const dispatch = async (i) => {
if (i <= index) throw new Error("next() called multiple times");
index = i;
const fn = this.#stack[i];
if (!fn) return; // end of the chain
await fn(context, () => dispatch(i + 1));
};
await dispatch(0);
return context;
}
}
This dispatch design is essentially how Koa’s koa-compose works. The guard against i <= index prevents a middleware from accidentally calling next() twice, which would otherwise re-run downstream handlers.
const pipeline = new Pipeline()
.use(async (ctx, next) => {
console.log("auth check");
if (!ctx.user) {
ctx.status = 401;
return; // short-circuit: never call next()
}
await next();
})
.use(async (ctx, next) => {
ctx.body = `Hello, ${ctx.user}`;
await next();
});
const result = await pipeline.run({ user: "ada" });
console.log(result);
Output:
auth check
{ user: 'ada', body: 'Hello, ada' }
Tip: Because each middleware awaits
next(), code placed after theawait next()call runs on the way back out — the “onion model.” This is how Koa middleware times a request or sets headers after downstream handlers finish.
Express vs. Koa style
The two dominant frameworks differ in how they expose the chain. Express passes separate req/res objects and a callback-style next; Koa wraps both in a single ctx and uses async functions so next() returns a promise you can await.
| Aspect | Express | Koa |
|---|---|---|
| Signature | (req, res, next) | (ctx, next) |
| Shared state | req / res | ctx (with ctx.request/ctx.response) |
| Flow control | next() callback | await next() |
| Post-processing | harder (no natural “after”) | natural via onion model |
| Error handling | dedicated 4-arg signature | try/catch around await next() |
Error-handling middleware
Express identifies error middleware purely by arity: a function with four parameters (err, req, res, next) is treated as an error handler and only invoked when an earlier middleware calls next(err) or throws synchronously. Register it last, after all routes, so it acts as a catch-all.
app.get("/risky", (req, res, next) => {
try {
throw new Error("boom");
} catch (err) {
next(err); // forward to the error handler
}
});
// Four arguments => Express treats this as error middleware
app.use((err, req, res, next) => {
console.error(`[error] ${err.message}`);
res.status(500).json({ error: err.message });
});
Output:
-> GET /risky
[error] boom
Gotcha: In Express 4, errors thrown inside an
asyncroute handler are not caught automatically — you mustnext(err)yourself or wrap handlers. Express 5 (and frameworks like Koa) forward rejected promises to the error chain for you.
In the Koa-style engine above, error handling is just a try/catch placed early in the pipeline:
pipeline.use(async (ctx, next) => {
try {
await next();
} catch (err) {
ctx.status = 500;
ctx.body = { error: err.message };
}
});
Best Practices
- Keep each middleware focused on one concern — parse, authenticate, log — so they stay reusable and easy to test in isolation.
- Always call
next()exactly once, or end the request; never both. Forgetting it hangs the connection. - Register middleware in deliberate order: cross-cutting concerns (logging, CORS, body parsing) before routes, error handlers last.
- Prefer async/await pipelines so you can use the onion model and
try/catchfor clean error propagation. - Mount middleware on specific paths (
app.use("/api", ...)) to avoid running global logic where it is not needed. - Pass data downstream by attaching it to the shared context (
req.user,ctx.state) rather than reaching into outer scope.