A chain of handlers, one next() at a time
Order is behaviour: a handler sees only what ran before it, and runs only if everything before it called next().
Predict first
Before you read: predict
ExamHall's proctor API runs every request through this chain. A request arrives with no signed-in user:
What does main.ts print?
// proctor-api.tsimport compose from 'koa-compose';type Ctx = { user?: string; status: number; body: string };type Next = () => Promise<void>;const timing = async (ctx: Ctx, next: Next) => { console.log('timing:before'); await next(); console.log(`timing:after ${ctx.status}`);};const auth = async (ctx: Ctx, next: Next) => { if (!ctx.user) { ctx.status = 401; ctx.body = 'sign in'; return; } await next();};const submitAnswer = async (ctx: Ctx) => { console.log('handler'); ctx.status = 200; ctx.body = 'answer saved';};export const proctorApi = compose([timing, auth, submitAnswer]); // main.tsimport { proctorApi } from './proctor-api';const ctx = { status: 404, body: '' };proctorApi(ctx).then(() => console.log(`sent ${ctx.status} ${ctx.body}`));The 30-second version
A middleware chain hands each request to a list of handlers in order. Each gets the request and next: it acts and calls next(), or answers and stops, so later handlers never run. Code after await next() runs on the way back out. So the list's order decides what each handler sees, and what runs.
Order is behaviour: a handler sees only what ran before it, and runs only if everything before it called next().
How it works
Step 1 of 11
ctx) and a next function; compose turns the list into one function that returns a promise.In the kitchen
- a traveller passes the health desk, passport control and customs, in that order → the request passing the list in order (step 1)
Analogy: A land border crossing
| At the border | In code |
|---|---|
| a traveller passes the health desk, passport control and customs, in that order | the request passing the list in order (step 1) |
| passport control refuses entry: customs never sees the traveller, but the health desk still logs them leaving | a short-circuit, and the outer handler resuming (step 3) |
| on the way back, travellers pass the desks they came through again | code after await next() runs on the way out (step 4) |
| customs placed before passport control searches the bags of people who will be refused anyway | cheap rejections first (steps 9–10) |
In real terms: before trusting a middleware, read the list it sits in: what runs before it is all it can rely on.
Breaks down when: an officer can wave someone through the same desk twice and nobody minds; in a chain a second next() is a bug (step 8).
Contrasting case: an event emitter. Every listener runs whatever the others do, none can stop the rest, and none sees what a later one did (pat-observer-pubsub).
Examples, run for real
4.1 ExamHall: parsing strangers' answers
EXH-4080: ExamHall's submit route parsed every request's answer sheet before checking who sent it:
// submit-route.ts (as shipped)
export const submitRoute = compose([parseAnswers, timing, auth, submitAnswer]);
During a flood of anonymous submits, every one was parsed and then refused. MR !4380 moves the cheap check to the front and the parser behind it:
// submit-route.ts (MR !4380)
export const submitRoute = compose([timing, auth, parseAnswers, submitAnswer]);
4.1 EXH-4080 as shipped: tsc no diagnostics · candidate 200 trail parse > timing:before > auth > handler > timing:after · 3 anonymous: parse ran 3, statuses 401 401 401
4.1 MR !4380: tsc no diagnostics · candidate 200 trail timing:before > auth > parse > handler > timing:after · 3 anonymous: parse ran 0, statuses 401 401 401
A candidate's request passes every handler in list order, and timing finishes last because it started first (steps 1, 4). After the MR, auth answers anonymous requests before parseAnswers is reached, so the parser runs only for signed-in candidates (steps 3, 9–10).
4.2 What the MR does not decide
Whether ExamHall's check should be middleware or a NestJS guard is a question about Nest's stages, not about chains (nest-request-lifecycle step 12).
4.3 Reading a chain you did not write
Read the list from the top and ask three things of each handler: on which paths does it call next(), what does it read from ctx that an earlier handler must have written, and what can it throw? Redux middleware and fetch interceptors answer the same three questions; only what next carries changes (steps 2, 9, 11).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
A film service builds its chain from four middleware and the stream handler:
The team needs three things: the access log holds a line for every request, with the status the client got; a request that fails inside the chain gets the JSON error body; a visitor with no token never receives a film another viewer loaded.
Which order meets all three?
// middleware.tstype Ctx = { token?: string; path: string; status: number; body: string; user?: string };type Next = () => Promise<void>;export const lines: string[] = [];const sessions = new Map([['tok-ana', 'ana']]);const cached = new Map<string, string>();export const accessLog = async (ctx: Ctx, next: Next) => { await next(); lines.push(`${ctx.path} ${ctx.status}`);};export const errors = async (ctx: Ctx, next: Next) => { try { await next(); } catch { ctx.status = 500; ctx.body = '{"error":"internal"}'; }};export const auth = async (ctx: Ctx, next: Next) => { if (ctx.token === undefined) { ctx.status = 401; ctx.body = 'sign in'; return; } if (!/^tok-\w+$/.test(ctx.token)) throw new Error('malformed token'); ctx.user = sessions.get(ctx.token); await next();};export const cache = async (ctx: Ctx, next: Next) => { const hit = cached.get(ctx.path); if (hit) { ctx.status = 200; ctx.body = hit; return; } await next(); if (ctx.status === 200) cached.set(ctx.path, ctx.body);};export const stream = async (ctx: Ctx) => { ctx.status = 200; ctx.body = `signed-url ${ctx.path} for ${ctx.user}`;}; // main.ts (ORDER is the four middleware, in the order chosen below)import compose from 'koa-compose';import { accessLog, errors, auth, cache, stream, lines } from './middleware';const app = compose([ORDER, stream]);async function serve(token: string | undefined, path: string) { const ctx = { token, path, status: 404, body: '' }; try { await app(ctx); } catch { ctx.status = 500; ctx.body = 'Internal Server Error'; } console.log(`${path} ${token ?? 'anonymous'}: ${ctx.status} ${ctx.body}`);}async function main() { await serve('tok-ana', '/films/7'); await serve(undefined, '/films/7'); await serve('tok-%%', '/films/9'); console.log(`log: ${lines.join('; ')}`);}main();Question 2 of 7
A transcoding API retries once when the worker is busy:
What does main.ts print?
// transcode-api.tsimport compose from 'koa-compose';type Ctx = { status: number; body: string };type Next = () => Promise<void>;let runs = 0;export const transcodeRuns = () => runs;const errors = async (ctx: Ctx, next: Next) => { try { await next(); } catch (e) { ctx.status = 500; ctx.body = (e as Error).message; }};const retryOnce = async (ctx: Ctx, next: Next) => { try { await next(); } catch { console.log('retrying'); await next(); }};const transcode = async (ctx: Ctx) => { runs += 1; if (runs === 1) throw new Error('worker busy'); ctx.status = 200; ctx.body = 'rendition ready';};export const transcodeApi = compose([errors, retryOnce, transcode]); // main.tsimport { transcodeApi, transcodeRuns } from './transcode-api';const ctx = { status: 404, body: 'not found' };transcodeApi(ctx).then(() => console.log(`sent ${ctx.status} ${ctx.body} · transcode ran ${transcodeRuns()}`));Question 3 of 7
A playlist endpoint is timed by a middleware written in a hurry. db.segments is an in-memory fake that answers on the next turn of the event loop, as a real query answers later:
What does main.ts print?
// playlist-api.tsimport compose from 'koa-compose';type Ctx = { videoId: string; status: number; body: string };type Next = () => Promise<void>;const db = { segments: (id: string) => new Promise<string[]>((resolve) => setImmediate(() => resolve([`${id}-1`, `${id}-2`, `${id}-3`]))),};const timing = async (ctx: Ctx, next: Next) => { console.log('timing:before'); next(); console.log('timing:after');};const playlist = async (ctx: Ctx) => { const rows = await db.segments(ctx.videoId); console.log('handler'); ctx.status = 200; ctx.body = `${rows.length} segments`;};export const playlistApi = compose([timing, playlist]); // main.tsimport { playlistApi } from './playlist-api';const ctx = { videoId: 'v9', status: 404, body: 'not found' };playlistApi(ctx).then(() => console.log(`sent ${ctx.status} ${ctx.body}`));Question 4 of 7
Anonymous visitors are getting films:
Which change gives an anonymous visitor 401 sign in, never signs a URL for one, and still sends ana her film?
// film-api.tsimport compose from 'koa-compose';type Ctx = { token?: string; status: number; body: string };type Next = () => Promise<void>;const viewers = new Map([['tok-ana', 'ana']]);export const signedFor: string[] = [];const auth = async (ctx: Ctx, next: Next) => { if (!viewers.has(ctx.token ?? '')) { ctx.status = 401; ctx.body = 'sign in'; } await next();};const stream = async (ctx: Ctx) => { signedFor.push(viewers.get(ctx.token ?? '') ?? 'anonymous'); ctx.status = 200; ctx.body = 'signed-url /films/7';};export const filmApi = compose([auth, stream]); // main.tsimport { filmApi, signedFor } from './film-api';async function main() { for (const token of ['tok-ana', undefined]) { const ctx = { token, status: 404, body: '' }; await filmApi(ctx); console.log(`${token ?? 'anonymous'}: ${ctx.status} ${ctx.body}`); } console.log(`URLs signed for: ${signedFor.join(', ')}`);}main();Question 5 of 7
compose(middleware) returns a function that takes ctx and an optional next, and returns a promise. That function runs the list in order, each handler getting ctx and a next that runs the rest; after the last handler it calls the optional next; it rejects a second call of the same next() with an Error whose message is next() called multiple times; a handler's throw, synchronous or not, rejects the returned promise.
Can you write compose(middleware) in 15 minutes, from a blank file that exports it?
Question 6 of 7
A box office's seat-hold route records each handler's last step. One seat is left, and two buyers ask for it in turn:
What does main.ts print?
// hold-api.tsimport compose from 'koa-compose';type Ctx = { status: number; trail: string[] };type Next = () => Promise<void>;let seatsLeft = 1;const venue = async (ctx: Ctx, next: Next) => { await next(); ctx.trail.push(`venue out ${ctx.status}`);};const quota = async (ctx: Ctx, next: Next) => { if (seatsLeft === 0) { ctx.status = 409; return; } await next(); ctx.trail.push('quota out');};const holdSeat = async (ctx: Ctx) => { seatsLeft -= 1; ctx.status = 201; ctx.trail.push('hold');};export const holdApi = compose([venue, quota, holdSeat]); // main.tsimport { holdApi } from './hold-api';async function main() { for (const buyer of ['ana', 'ben']) { const ctx = { status: 404, trail: [] as string[] }; await holdApi(ctx); console.log(`${buyer}: ${ctx.trail.join(' > ')}`); }}main();Question 7 of 7
A ledger's payment route keeps an audit trail of every request:
What does main.ts print?
// ledger-api.tsimport compose from 'koa-compose';type Ctx = { path: string; status: number; body: string };type Next = () => Promise<void>;export const auditLines: string[] = [];class Refused extends Error { constructor(public status: number) { super('refused'); } }const errors = async (ctx: Ctx, next: Next) => { try { await next(); } catch (e) { ctx.status = e instanceof Refused ? e.status : 500; ctx.body = 'payment refused'; }};const audit = async (ctx: Ctx, next: Next) => { await next(); auditLines.push(`${ctx.path} ${ctx.status}`);};const postPayment = async (ctx: Ctx) => { if (ctx.path === '/pay') throw new Refused(402);};export const ledgerApi = compose([errors, audit, postPayment]); // main.tsimport { ledgerApi, auditLines } from './ledger-api';const ctx = { path: '/pay', status: 404, body: 'unanswered' };ledgerApi(ctx).then(() => console.log(`sent ${ctx.status} ${ctx.body} · audit: ${auditLines.join('; ') || 'no lines'}`));Go deeper
Pro tips
- Put cheap rejections first. Rate limits and authentication go before expensive parsing and validation, so hostile traffic costs the least. When: adding a middleware to a public route (steps 9–10).
- Test the chain, not only each handler. Give each short-circuit path one test that asserts which handlers ran, not only the status. When: a route gains a new early check (steps 2–3). Verify (RAN,
tips.mjs): a chain of a rate limiter, authentication and a handler, sent a request over its quota, ran exactly one handler (rateLimit), and the same test fails when the rate limiter is moved after authentication (two handlers ran).
Source
- koa-compose (the row's second source): https://github.com/koajs/compose ·
Readme.mdfetched 2026-10-13: "Compose the given middleware and return middleware." The tagged 4.1.0index.js(https://raw.githubusercontent.com/koajs/compose/4.1.0/index.js, fetched 2026-10-13,sources/koa-compose-4.1.0-index.txt) is byte-identical to the npm 4.1.0 package that runs every item; it holdsif (i <= index) return Promise.reject(new Error('next() called multiple times'))(step 8). Teaching and runtime only; every key here is executed. Valid as of 2026-10-13. - Koa, "Cascading" (not named by the row; teaching only): https://koajs.com/ · fetched 2026-10-13. "When a middleware invokes next() the function suspends and passes control to the next middleware defined. After there are no more middleware to execute downstream, the stack will unwind and each middleware is resumed to perform its upstream behaviour" (steps 3–4). Valid as of 2026-10-13.
- The row's first source, NestJS's "Request lifecycle" page, is taught and cited by the prerequisite
nest-request-lifecycle; this lesson serves no item on Nest's stages. The row's third source, Gamma, Helm, Johnson and Vlissides, Design Patterns (1994), Chain of Responsibility, is a book and keys nothing.
Related lessons
Checked against the tools and docs as of 2026-10-13.