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.
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.
- Logger runs its upstream code (e.g., records the start time).
- Logger calls
await next()— control passes to Auth. - Auth validates the token, then calls
await next()— control passes to Handler. - Handler sets
ctx.bodyand does not callnext(). - Control returns to Auth’s downstream code (after its
await next()). - 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
| Phase | Runs when | Typical uses |
|---|---|---|
Upstream (before await next()) | On the way in | Parse body, authenticate, start timer |
Downstream (after await next()) | On the way out | Set headers, log response time, compress |
| Phase | Runs when | Typical uses |
|---|---|---|
Upstream (before await next()) | On the way in | Parse body, authenticate, start timer |
Downstream (after await next()) | On the way out | Set 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.