Middleware: The Onion Model

The dashboard said p99 latency was 0.4 milliseconds. Support said the orders page took six seconds. Both were telling the truth.

Here is the middleware that produced the number. If you have written Express you have probably written this one, or pasted it from an answer with four hundred upvotes:

app.use(async (req, res, next) => {
  const start = performance.now();
  await next();
  const ms = performance.now() - start;
  res.set('X-Response-Time', ms.toFixed(1) + 'ms');
  metrics.observe(req.method, ms);
});

Read it out loud and it is obviously correct. Start a clock, run the rest of the app, stop the clock.

It is measuring nothing. In Express, next() returns undefined. So await next() is await undefined, which resumes on the very next microtask tick, roughly 0.05ms later, while the database call it was supposed to be timing has not even left the machine. The four seconds Postgres spent were never in that number and never could be.

Move the same eight lines into Koa or Hono and they are exactly right.

Same words, same shape, opposite meaning. The difference is what next actually is, and that is the only genuinely hard thing about middleware.

A function with a hole in it

Strip the frameworks off. A request handler takes a request and produces a response:

function handler(req, res) {
  res.end('hello');
}

A middleware is that, with a hole punched in the middle:

function middleware(req, res, next) {
  // ...before
  next();
  // ...after
}

next is the entire rest of your application, packed into one function that you may or may not call. That one decision buys three powers:

  • Before. Code that runs holding the request, before anything downstream has seen it. Mint an id, parse a body, check a token.
  • After. Code that runs once everything downstream has finished. Log the status, record the duration, set a header.
  • Never. Don’t call it. Answer right here and the rest of the app does not exist for this request.

That is the whole abstraction. Everything a framework adds is bookkeeping around those three.

Build the chain yourself

Frameworks make this feel like magic. It is thirteen lines.

function compose(stack) {
  return function run(ctx, i = 0) {
    const fn = stack[i];
    if (!fn) return Promise.resolve();       // fell off the end

    let called = false;
    const next = () => {
      if (called) throw new Error('next() called twice in layer ' + i);
      called = true;
      return run(ctx, i + 1);
    };

    return Promise.resolve(fn(ctx, next));
  };
}

Four things are happening, and each one matters.

run(ctx, i) calls layer i and hands it a next that calls layer i + 1. Recursion, not a loop. A loop has nowhere for the “after” code to come back to.

next() returns run(ctx, i + 1), a promise for everything downstream, all the way to the handler and back. That is the single line making await next() mean “wait until the rest of the app is done”. Delete the return and the onion flattens into a plain list, which is exactly the bug from the opening.

Promise.resolve(fn(ctx, next)) normalises the layer. Sync middleware returns undefined, async returns a promise, and the caller cannot tell. Both become awaitable.

The called flag is not decoration. Calling next() twice runs the rest of the chain twice: two responses on one socket, ERR_HTTP_HEADERS_SENT at 3am, nothing pointing at the layer that did it. Catch it where you still know the index.

Wire it up:

const app = compose([
  async (ctx, next) => { console.log('A in');  await next(); console.log('A out'); },
  async (ctx, next) => { console.log('B in');  await next(); console.log('B out'); },
  async (ctx)       => { console.log('handler'); ctx.body = 'hi'; },
]);

await app({});
A in
B in
handler
B out
A out

That output is the entire article. In, in, work, out, out. A wraps B wraps the handler, and the nesting comes for free from the fact that await next() is a normal function call that eventually returns.

The onion

Picture each layer as a ring around the next. The request travels inward through every ring to reach the handler, and the response travels back out through the same rings in reverse.

handlerrequestIdmint the idtimerstart = now()authverify the tokenjsonparse the body← nothing to do← nothing to do← record the duration← set x-request-idrequestresponsetop of each ring = code before next() · bottom of each ring = code after next()
Each layer wraps the next. Code above next() runs on the way in; code after it runs on the way out, in reverse order.

Notice two rings have nothing to do on the way out. That is normal. auth and json do their work on the way in and have no opinion about the response. The onion does not force you to use both phases; it just guarantees both phases exist.

Flatten the same picture into a timeline and the shape is one you have seen a thousand times. It is a call stack.

time1 · requestIdmint a uuidin2 · timerstart = now()in3 · authverify the tokenin4 · jsonparse the bodyin5 · handlerawait db.query() then res.json()turn6 · jsonnothing to doout7 · authnothing to doout8 · timerduration = now() - startout9 · requestIdset x-request-id headeroutindentation is depth in the chain; step 5 is the only one that talks to your database
The same chain as a timeline. Indentation is depth, top to bottom is time, and the handler is the turn.

Steps 8 and 9 are the whole reason to care. There is no other place in your app where you can say “however this request ended, log it with its id and its duration” exactly once. Not in the handler (there are hundreds of handlers, and half of them return early). Not after app.listen. Only here, on the way out.

Play with the chain

Toggle layers off, reorder them with the arrows, drop the token, and watch what runs. The runner underneath is the same compose from above, and every trace line is real: the indentation is the actual recursion depth.

interactiveA middleware chain you can reorder

Two experiments worth running before you read on. Move validate above json and send. Then uncheck the Authorization box and send again. Those are the next two sections.

Order is the program

There is no dependency graph. There is no annotation that says “this should run first”. There is a list, and it runs top to bottom. Middleware order is your program’s order of operations, and it is expressed as the physical order of lines in a file that four people edit.

The classic version of the mistake looks harmless:

app.use(requireName);          // reads req.body.name
app.use(express.json());       // ...sets req.body

function requireName(req, res, next) {
  if (!req.body?.name) return res.status(400).json({ error: 'name is required' });
  next();
}

Every request now gets a 400. Valid ones too. Forever.

✗ validate registered before express.json()validateexpress.json()handlerreads req.bodyundefinednever runsnever runs400every request✓ express.json() firstexpress.json()validatehandlerreads req.body{ name: ‘ada’ }201created
Same two layers, two orders. The one on top rejects every request; the one below works. Nothing else differs.

The orderings that actually bite

Four rules cover most of it, and each one has a reason rather than a convention behind it.

Body parsing before anything that reads a body. Validation, signature checks, anything touching req.body. See above. Related: an HMAC webhook signature has to be computed over the raw bytes, so a signature check needs the raw body captured before or during parsing, not the re-serialised object after.

Rate limiting before body parsing. Otherwise you buffer and JSON-parse a 10MB payload for a request you had already decided to reject. That is free CPU for whoever is attacking you. Put the cheap rejection first. (Rate limiting goes into what to key on.)

CORS before auth. This one produces the most confusing bug report in the genre. A browser preflight is an OPTIONS request that carries no cookie and no Authorization header, by design. If your auth layer sits above your CORS layer, the preflight gets a 401 with no Access-Control-Allow-Origin on it, the browser refuses to look at the status, and the frontend engineer reports “CORS error” for what is really an ordering bug on your side. The browser half of this is in Fetch: Cross-Origin Requests.

Request id before logging. Obvious once stated, invisible until you need it. If requestId runs after logger, every log line for that request has no id, and correlating a user’s complaint to a trace becomes archaeology. (Observability leans on this hard.)

Error handling last. Not because it runs last (it doesn’t, it runs when something upstream fails) but because of how frameworks find it. More on that below.

Answering early

The third power. Don’t call next and the request stops with you.

function requireAuth(req, res, next) {
  const token = req.headers.authorization?.slice(7);
  if (!token) return res.status(401).json({ error: 'missing token' });
  //  ^^^^^^ this return is the entire security control

  req.user = verify(token);
  next();
}
auth returns without calling next()requestIdtimerauthjsonhandlernever runsnever runsrequest401401 Unauthorizedout phases still runnever run at all
Auth answers 401 and never calls next(). The layers inside it never execute, but the layers outside it still get their turn on the way out.

That bracket on the left is the part people get wrong. Short-circuiting does not abort the chain. It stops the request going any deeper. Everything outside the layer that answered still unwinds normally, which is exactly what you want: your 401 still gets an x-request-id, still gets logged, still gets counted in your metrics. If short-circuiting really did abort, every rejected request would be invisible, and “we have no logs for the users who can’t log in” is a bad place to be.

Hono makes this one structurally harder to get wrong: a middleware either await next()s and returns nothing, or returns a Response to exit early. There is no “send and keep going” state to fall into.

Two kinds of next()

Now back to the opening.

Everything so far assumed next() returns a promise for the work downstream. Koa and Hono do that. Express does not. Express’s next is a plain function that walks its layer stack and returns undefined, and the router keeps a sync counter that breaks the call stack with setImmediate after 100 consecutive synchronous layers so a long chain cannot blow the stack.

Koa / Hono · next() returns a promise for everything downstreamtimerhandlerstartawait db.query() · 4000 msrecordmeasured 4002 ms · correctExpress · next() returns undefinedtimerhandlerstartrecorddb round trip, 4000 ms, nothing is awaiting itres.json()measured 0.1 msthe request really took 4002 ms
The same timing middleware on two runtimes. Only one of them is measuring the request.

Follow the bottom track. next() synchronously enters the handler; the handler runs until its first await and returns a pending promise; next() returns undefined; the timer’s “after” code resumes one microtask later and records 0.1ms, sets the header (the response has not been sent yet, so nothing complains), and four seconds afterwards the handler wakes up and ships a response carrying a completely fictional X-Response-Time.

It gets stranger. If the handler happens to be synchronous, next() doesn’t return until after res.end() has already fired, so res.set() on the microtask afterwards throws ERR_HTTP_HEADERS_SENT instead. Whether your middleware silently lies or loudly crashes depends on whether the handler under it happened to await anything. That is not a distinction you want in a production dependency.

When the throw is async

Express finds error handlers by counting parameters. Literally:

// from the router: Layer.prototype.handleError
if (fn.length !== 4) {
  return next(error);      // not an error handler, skip it
}

Four parameters means error handler. Three or fewer means normal middleware. There is no registration, no flag, no app.onError. Your function’s arity is the API.

app.use((err, req, res, next) => {          // 4 params → error handler
  const status = err.status ?? 500;
  if (res.headersSent) return next(err);    // too late; let Express kill the socket
  logger.error({ err, id: req.id });
  res.status(status).json({ error: status === 500 ? 'internal' : err.message });
});

It goes last, after every route, because the router scans its stack in registration order looking for the next layer with four parameters. Register it at the top and nothing beneath it will ever reach it.

Async throws

Express 5 fixed the biggest wart here. A handler or middleware that returns a rejected promise now gets forwarded automatically:

// Express 4: this crashes the process. Express 5: this is a 500.
app.get('/users/:id', async (req, res) => {
  const user = await db.user(req.params.id);   // throws → next(err) for you
  res.json(user);
});

The mechanism is one line in the router: it takes whatever your function returned, checks if it’s a promise, and attaches ret.then(null, (error) => next(error || new Error('Rejected promise'))). Note what that is and isn’t. It attaches a rejection handler; it never awaits the fulfilled value, and it does not wait for you before moving on. Your middleware still drives the chain by calling next() itself.

So auto-forwarding only reaches errors inside the promise your function returned. Anything that escapes that chain is still an uncaught exception that takes the process down:

app.get('/report', async (req, res) => {
  setTimeout(() => {
    throw new Error('boom');    // NOT caught. Different tick, different stack.
  }, 100);
  res.json({ queued: true });
});

Same for a throw inside a stream’s 'data' listener, an EventEmitter callback, or anything you fired without awaiting. Express never sees it. Node’s default is to print it and exit. If it isn’t reachable by an await from the function Express called, it is not Express’s problem.

In a real onion, the error handler is a try/catch

Once await next() genuinely means “the rest of the app”, errors stop being a special mechanism:

// Koa / Hono style: outermost layer, ordinary language features
app.use(async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    ctx.status = err.status || 500;
    ctx.body = { error: ctx.status === 500 ? 'internal' : err.message };
    ctx.app.emit('error', err, ctx);
  }
});

No arity rule, no four-argument convention, no “register it last”. It is the outermost layer because it is written first, and a try around a call catches everything that call throws. That is the return value earning its keep.

What belongs in the chain

The test is simple: does it apply to a whole class of requests, and would it be identical if you copy-pasted it into thirty handlers? Then it’s a middleware. Otherwise it’s a function you call from a handler.

The usual residents, roughly in the order you want them:

Layer Phase Why it sits where it does
request id in + out Everything below it wants the id; the out phase echoes it on the response
logger in + out Needs the id above it; needs the status from below it
CORS in Must answer the preflight before auth can reject it
compression out mostly Wraps the response stream, so it goes above whatever writes
rate limit in Reject before you spend anything, including body parsing
body parsing in Before validation, after the cheap rejections
auth in Before the handler, after CORS

Request id is worth one more paragraph because the naive version is annoying. You mint req.id, and then every function five calls deep needs it, so you start threading id through signatures that have no business knowing about HTTP. AsyncLocalStorage is the answer, it is Stability 2 (stable) in Node, and Node 24’s rewritten implementation (AsyncContextFrame by default) made it both faster and less likely to lose context across gnarly async boundaries:

import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';

export const store = new AsyncLocalStorage();

app.use((req, res, next) => {
  const id = req.headers['x-request-id'] ?? randomUUID();
  res.setHeader('x-request-id', id);
  store.run({ id }, next);        // everything downstream can read it
});

// anywhere, any depth, no plumbing
export const log = (msg) => console.log(store.getStore()?.id ?? '-', msg);

store.run({ id }, next) is the middleware. It calls next inside the context, so every await beneath it inherits the store. Read the incoming x-request-id first if you have a proxy in front, otherwise you break the trace at your own front door.

Summary

  • A middleware is a function that receives the request and holds next, which is the rest of your application packed into one function. Call it before, after, or not at all.
  • The whole runner is thirteen lines. The load-bearing one is return run(ctx, i + 1) inside next. Returning that promise is what makes await next() mean “wait for everything downstream”.
  • The onion is a call stack. Code above next() runs inward in registration order; code below it runs outward in reverse. The out phase is the only place to say “however this ended, log it once with its id and duration”.
  • Order is the program. Body parsing before validation, rate limiting before body parsing, CORS before auth, request id before logging. There’s no dependency resolution, just a list of lines in a file.
  • On Express 5, req.body is undefined until a parser runs, not {}. Reaching for ?. to silence the crash converts a loud bug into a silent 400 on every request.
  • Short-circuiting stops the request going deeper, it doesn’t abort the chain. The layers outside the one that answered still unwind, which is why your 401 still gets logged. Forget the return before res.status(401) and you ship an auth bypass that passes its tests.
  • Express’s next() returns undefined. await next() is await undefined. Timing and logging on the way out are a fiction there; use res.on('finish'). Koa and Hono return a real promise, so await next() means what it reads like. Fastify skips the metaphor and gives you named lifecycle hooks.
  • Express finds error handlers by fn.length === 4. A default value or a rest parameter silently drops the arity and turns your error handler into a normal middleware that crashes on healthy requests. Take the ugly four parameters.
  • Express 5 forwards rejected promises from handlers automatically, but only from the promise your function returned. A throw inside setTimeout or an event listener is still an uncaught exception. And next(); await thing() will call your error handler after the response has already shipped, which is why every error handler checks res.headersSent.
  • Cross-cutting concerns belong in the chain. Domain logic does not. If it applies to one route, it’s the first line of that route.