EngineeringMarch 28, 2024 · 4 min readUpdated September 6, 2026

Middleware Pattern in Node.js

Part of my Node.js Design Patterns series.

Every webhook we receive from a partner goes through the same gate: check the signature, log the raw body, reject anything older than five minutes, parse it, then hand it to whatever cares about that event type. When those steps lived inside each handler, one partner's handler forgot the timestamp check, and we replayed a day-old batch of cancellations onto live orders. Not a dramatic bug. A missing line in one of nine files.

The Middleware pattern makes the gate a list. Each step is a function that does its part and either calls next() or stops the chain. Express and Koa made it famous, but the pattern needs no framework, and it is worth owning twenty lines of it so you can use it for things that are not HTTP: a message consumer, a CLI, a job runner.

The pattern in modern Node

pipeline.mjsJavaScript
// compose([...fns]) returns one function that runs them in order.
// Each fn is async (ctx, next). Not calling next() stops the chain.
export function compose(fns) {
  return async function run(ctx) {
    let index = -1;
    async function dispatch(i) {
      if (i <= index) throw new Error("next() called twice in the same middleware");
      index = i;
      const fn = fns[i];
      if (!fn) return;
      await fn(ctx, () => dispatch(i + 1));
    }
    await dispatch(0);
  };
}
app.mjsJavaScript
import { compose } from "./pipeline.mjs";
 
const verifySignature = async (ctx, next) => {
  if (ctx.headers.signature !== "ok") {
    ctx.status = 401;
    return; // stop here, no next()
  }
  await next();
};
 
const log = async (ctx, next) => {
  const start = performance.now();
  await next();
  console.log(`${ctx.partner} ${ctx.status} in ${(performance.now() - start).toFixed(1)}ms`);
};
 
const rejectStale = async (ctx, next) => {
  const ageMs = Date.now() - ctx.body.sentAt;
  if (ageMs > 5 * 60_000) {
    ctx.status = 409;
    return;
  }
  await next();
};
 
const handle = async (ctx) => {
  ctx.status = 200;
  ctx.result = `processed ${ctx.body.event}`;
};
 
const webhook = compose([log, verifySignature, rejectStale, handle]);
 
const fresh = { partner: "grab", headers: { signature: "ok" }, body: { event: "order.created", sentAt: Date.now() } };
const stale = { partner: "grab", headers: { signature: "ok" }, body: { event: "order.cancelled", sentAt: Date.now() - 3_600_000 } };
const forged = { partner: "shopee", headers: { signature: "nope" }, body: { event: "order.created", sentAt: Date.now() } };
 
for (const ctx of [fresh, stale, forged]) {
  await webhook(ctx);
  console.log(" ->", ctx.status, ctx.result ?? "(rejected)");
}

Two things to notice. log runs code after await next(), so it sees the final status and the elapsed time. That "onion" shape, where a middleware wraps everything after it, is what makes timing, error handling, and transactions fit naturally. And rejectStale is now one line in one list, so no handler can forget it.

Error handling in the chain

Because every step awaits the next, a throw anywhere unwinds through the earlier steps. Put one error middleware first and it catches everything:

errors.mjsJavaScript
export const catchErrors = async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    ctx.status = 500;
    ctx.error = err.message;
  }
};

Order matters: catchErrors must be before anything that can throw, which is why the list is worth reading top to bottom whenever it changes.

Where it goes wrong

Calling next() twice. Usually from a missing return before await next() inside a conditional. The i <= index guard in compose above turns that silent double-run into a thrown error, which is the single most valuable line in the file.

Forgetting to await next(). The chain continues, but the outer middleware finishes before the inner ones, so timing is wrong and error handling misses everything below. Lint rules for floating promises catch this; so does reading log output that says 0.1ms for a database call.

The context bag grows without a shape. ctx.user, ctx.parsedBody, ctx.tenant, added by whichever middleware got there first, read by whichever handler assumes it. Declare the context type (a JSDoc typedef or a .ts file, which Node now runs directly) and treat new fields as a small design decision.

Middleware that does business logic. "Also send the confirmation SMS" inside a middleware is invisible to anyone reading the handler. Middleware is for cross-cutting concerns: auth, logging, validation, transactions. The thing the request is for belongs at the end of the chain.

When not to use it

A function that calls three others in a row is a pipeline already; you do not need compose for it. The pattern earns its place when the list changes (partners come and go), when steps must be able to stop the chain, or when the same list is reused across many entry points.

Next and last: the Decorator Pattern, for wrapping retry and caching around a client without editing it.

Nguyễn Hải Nam

Nguyễn Hải Nam

Project Management Lead. 16+ years from code to delivery. PMP®. Writing here about project management and engineering.

About me