The Onion Model

Every Request Travels In, Then Back Out — Twice Through Every Middleware

The Onion Model

Koa's cascading middleware pipeline is shaped like an onion: each layer runs code before calling next, then resumes after downstream layers finish.

4 min read Level 2/5 #koa#middleware#onion
What you'll learn
  • Explain the onion/cascade model and how control flows through it
  • Distinguish upstream (before next) from downstream (after next) code
  • Trace a multi-middleware request from entry to response

Koa’s single most important concept is the onion model. Middleware is not a queue that runs top-to-bottom once — it is a set of concentric layers. A request enters the outermost layer, passes inward through each layer until the innermost handler produces a response, then travels back out through every layer in reverse order.

The Pipeline in Prose

Picture three middleware functions: Logger → Auth → Handler.

  1. Logger runs its upstream code (e.g., records the start time).
  2. Logger calls await next() — control passes to Auth.
  3. Auth validates the token, then calls await next() — control passes to Handler.
  4. Handler sets ctx.body and does not call next().
  5. Control returns to Auth’s downstream code (after its await next()).
  6. Control returns to Logger’s downstream code, which now knows the final status code and can log the response time.

Code That Shows the Shape

import Koa from 'koa';
const app = new Koa();

app.use(async (ctx, next) => {
  console.log('→ mw1 upstream');
  await next();
  console.log('← mw1 downstream');
});

app.use(async (ctx, next) => {
  console.log('  → mw2 upstream');
  await next();
  console.log('  ← mw2 downstream');
});

app.use(async (ctx) => {
  console.log('    handler — setting body');
  ctx.body = 'hello';
});

app.listen(3000);

A single GET / request prints:

→ mw1 upstream
  → mw2 upstream
    handler — setting body
  ← mw2 downstream
← mw1 downstream

Upstream vs Downstream

PhaseRuns whenTypical uses
Upstream (before await next())On the way inParse body, authenticate, start timer
Downstream (after await next())On the way outSet headers, log response time, compress
PhaseRuns whenTypical uses
Upstream (before await next())On the way inParse body, authenticate, start timer
Downstream (after await next())On the way outSet headers, log response time, compress

Why await Is Not Optional

Because each middleware is async, calling next() without await returns a Promise that nobody waits on. Downstream code in earlier layers then runs before inner layers have finished — breaking the cascade.

// WRONG — upstream runs out of order
app.use(async (ctx, next) => {
  next();                    // missing await!
  console.log(ctx.status);  // always 404, body not set yet
});

Always await next() unless you deliberately want to skip waiting for downstream resolution.

Up Next

Learn how to write your own middleware from scratch, including when to skip next() entirely.

Writing Your Own Middleware →