Cascading Middleware

await next() Yields Downstream Then Resumes Upstream — the Onion Model

Cascading Middleware

Koa's defining feature is cascading middleware where each function wraps the rest of the stack, enabling clean before/after hooks like request timing inside a single async function.

4 min read Level 2/5 #koa#cascading#middleware
What you'll learn
  • Explain the onion model of middleware execution in Koa
  • Implement a request-timing middleware using await next()
  • Understand the difference between downstream and upstream execution

Cascading middleware is the feature that makes Koa different from almost every other Node framework. Because next is a promise, a middleware function can do work before the rest of the stack, yield control with await next(), and then continue after all downstream middleware have completed — in the same function, with no callbacks.

The Onion Model

Picture the middleware stack as concentric layers of an onion. A request enters at the outermost layer and travels inward (downstream) through each await next() call. After the innermost layer runs, control flows back outward (upstream) through each function’s code after its await next().

         ┌─── Middleware A ───────────────────┐
         │   ┌─── Middleware B ────────────┐  │
         │   │   ┌─── Middleware C ─────┐  │  │
Request──┼──►│──►│                     │  │  │
         │   │   └─── Middleware C ─────┘  │  │
Response─┼──◄│──◄│                     │  │  │
         │   └─── Middleware B ────────────┘  │
         └─── Middleware A ───────────────────┘

Request Timing Example

A practical use of the onion model is measuring how long a request takes to process:

import Koa from "koa";

const app = new Koa();

// Timing middleware — wraps everything below it
app.use(async (ctx, next) => {
  const start = Date.now();
  await next();                              // downstream runs here
  const ms = Date.now() - start;
  ctx.set("X-Response-Time", `${ms}ms`);
  console.log(`${ctx.method} ${ctx.url} — ${ms}ms`);
});

// Logger middleware
app.use(async (ctx, next) => {
  console.log(`Handling ${ctx.path}`);
  await next();
});

// Response middleware
app.use(async (ctx) => {
  ctx.body = { status: "ok" };
});

app.listen(3000);

For every request the execution order is:

  1. Timing middleware: record start
  2. Logger middleware: log the path
  3. Response middleware: set ctx.body
  4. Logger middleware: (nothing after next)
  5. Timing middleware: calculate elapsed time, set header, log

Why This Matters

In Express, achieving the same timing result requires either a response-event hook or a monkey-patched res.end. In Koa it is just two lines around await next() — and the control flow is obvious from reading the code.

Up Next

See how async/await makes error handling and control flow far cleaner than Express-style callbacks.

Async Flow and Error Handling →