An incoming HTTP request has to be authenticated, rate-limited, checked against a cache, and finally routed. Any of those steps might end it early.
What people write first
function handle(request: Request): Response { if (!request.user) return unauthorized(); if (overRateLimit(request)) return tooManyRequests();
const cached = cache.get(request); if (cached) return cached;
return route(request);}Honestly? For four fixed steps this is good code, and you should not replace it.
It becomes a problem when the set of steps varies — when some routes need CORS and others need a tenant lookup, when a plugin wants to insert a step, or when the order needs to be configurable. Then this function grows flags, and the flags interact.
The pattern
Each step is an object that either handles the request or passes it on.
interface Handler { setNext(next: Handler): Handler; handle(request: Request): Response | null;}
abstract class BaseHandler implements Handler { private next?: Handler;
setNext(next: Handler): Handler { this.next = next; return next; // returns next, so chains read in order }
handle(request: Request): Response | null { return this.next?.handle(request) ?? null; }}
class RateLimit extends BaseHandler { handle(request: Request) { if (overRateLimit(request)) return tooManyRequests(); return super.handle(request); // ← not mine; pass it on }}Watch a request walk the chain:
request: cached
auth looks at the request, decides it is not its concern, and passes it along. It does not know who is next; it only knows there is a next.
Assembly is separate from the handlers, which is the point:
const chain = new Auth();chain.setNext(new RateLimit()).setNext(new Cache()).setNext(new Router());The middleware variant
Modern frameworks use a version where each step can act after the rest of the chain as well as before:
type Middleware = (req: Request, next: () => Promise<Response>) => Promise<Response>;
const timing: Middleware = async (req, next) => { const started = Date.now(); const response = await next(); // ← everything downstream metrics.timing('request', Date.now() - started); return response;};That single await next() is what lets one handler wrap the whole remainder —
logging, timing, transactions, error boundaries. It is strictly more powerful
than the classic version, which can only decide whether to continue.
Express, Koa, ASP.NET Core and Django all use this shape. If you are writing a chain today, write this one.
Where it already is
Servlet filters, DOM event bubbling, exception handlers unwinding a stack, logging frameworks passing a record up through appenders, and Unix signal handlers. All the same shape: an ordered list, each member free to stop the walk.