Build Your Own Middleware Pipeline
Strip Express and Koa down to their core: a 30-line compose() function that chains async middleware with before/after hooks, short-circuit support, and error propagation.
Build Your Own Middleware Pipeline
You've been calling app.use() for years. Every Express or Koa app you've written threads a chain of functions — logging, auth, parsing, routing — before a request ever reaches your handler. But how does next() actually work? Why does awaiting it in Koa let you run code after downstream handlers finish? And what happens when you call next() twice?
Strip away the framework and there are about 30 lines of JavaScript underneath. Here's how it works.
What We're Building
compose(middlewares)— takes an array of async middleware functions and returns a single function that runs them in order- Each middleware gets a
(ctx, next)signature — callnext()to pass control downstream, optionallyawaitit to run code after - Error propagation: throw anywhere in the chain and the error surfaces at the call site
- A tiny HTTP router wired on top to show it working end-to-end
Each function has two "slots" — code that runs before calling next(), and code that runs after it resolves. The request goes in at the top, fans down through the "before" halves, hits the handler at the center, then unwinds back up through the "after" halves. This is why Koa calls it the onion model.
Step 1: The Synchronous Version
Start with the simplest possible thing — a synchronous pipeline where each middleware calls next as a plain function.
Synchronous compose
// pipeline.js
function composeSyncPipeline(middlewares) {
return function execute(ctx) {
let i = 0;
function next() {
const fn = middlewares[i++];
if (fn) fn(ctx, next);
}
next();
};
}
// --- demo ---
const run = composeSyncPipeline([
(ctx, next) => { ctx.log.push('A before'); next(); ctx.log.push('A after'); },
(ctx, next) => { ctx.log.push('B before'); next(); ctx.log.push('B after'); },
(ctx, next) => { ctx.log.push('handler'); },
]);
const ctx = { log: [] };
run(ctx);
console.log(ctx.log);
// ['A before', 'B before', 'handler', 'B after', 'A after']This works as long as every middleware is synchronous. The moment one of them does await fetch(...) before calling next(), that middleware returns a Promise at its first await and control comes straight back to its caller. next() has returned, so 'A after' runs while B is still suspended, and the ordering collapses.
The fix: make next() return a Promise and await it.
Step 2: Async Compose
Async compose — the real implementation
// pipeline.js
function compose(middlewares) {
if (!Array.isArray(middlewares)) {
throw new TypeError('middlewares must be an array');
}
for (const fn of middlewares) {
if (typeof fn !== 'function') {
throw new TypeError('each middleware must be a function');
}
}
// Returns a function that runs the whole pipeline
return function (ctx, finalNext) {
let calledIndex = -1;
function dispatch(i) {
// Catch next() being called twice from the same middleware
if (i <= calledIndex) {
return Promise.reject(new Error('next() called multiple times'));
}
calledIndex = i;
// Past the end of the array — call the optional finalNext, or resolve
const fn = i < middlewares.length ? middlewares[i] : finalNext;
if (!fn) return Promise.resolve();
try {
// fn receives ctx and a bound dispatch for the next slot
return Promise.resolve(fn(ctx, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err);
}
}
return dispatch(0);
};
}
module.exports = { compose };The calledIndex guard is the most important line. Without it:
async function badMiddleware(ctx, next) {
await next(); // advances index
await next(); // called again — without the guard, runs the next fn twice
}With the guard, the second call rejects immediately with a clear error instead of silently duplicating work.
Promise.resolve(fn(...)) is the adapter. If fn returns a plain value or is a synchronous function, Promise.resolve() wraps it. If fn is already async (returns a Promise), Promise.resolve() is a no-op. Either way, the caller always gets a Promise back and can await the whole chain.
Step 3: Using It
const { compose } = require('./pipeline');
const logger = async (ctx, next) => {
const start = Date.now();
await next();
console.log(`${ctx.method} ${ctx.path} — ${Date.now() - start}ms`);
};
const authenticate = async (ctx, next) => {
if (!ctx.headers.authorization) {
ctx.status = 401;
ctx.body = 'Unauthorized';
return; // don't call next — short-circuit the pipeline
}
ctx.user = parseToken(ctx.headers.authorization);
await next();
};
const handler = async (ctx) => {
ctx.status = 200;
ctx.body = { hello: ctx.user.name };
};
const pipeline = compose([logger, authenticate, handler]);
// Simulate a request context
const ctx = {
method: 'GET',
path: '/api/me',
headers: { authorization: 'Bearer abc123' },
status: null,
body: null,
};
pipeline(ctx).then(() => {
console.log(ctx.status, ctx.body);
});authenticate doesn't call next() when auth fails. The pipeline short-circuits — handler never runs, logger still executes its "after" code (the timing line), because await next() resolved at the point where authenticate returned without forwarding.
Short-circuiting by not calling next() is the canonical middleware pattern for guards. You set the response on ctx and return. Every middleware above you in the stack will still finish its "after" phase.
Step 4: Error Handling
The try/catch in dispatch means any thrown error (sync or async) turns into a rejected Promise that bubbles up the chain. Add an error-handling middleware at the top to catch it:
Error boundary middleware
const errorHandler = async (ctx, next) => {
try {
await next();
} catch (err) {
ctx.status = err.status || 500;
ctx.body = { error: err.message };
console.error('[error]', err);
}
};
const riskyHandler = async (ctx) => {
if (!ctx.params.id) throw Object.assign(new Error('Missing id'), { status: 400 });
ctx.body = await db.findUser(ctx.params.id); // could throw
};
const pipeline = compose([errorHandler, logger, authenticate, riskyHandler]);errorHandler is first in the array. When any downstream middleware throws, the await next() call inside errorHandler rejects, the catch block runs, and you write a clean error response.
Now the part that bites people. On the error path, logger never prints its timing line. riskyHandler throws, so await next() inside logger throws too, and every statement after it in logger's body is skipped as the error unwinds toward errorHandler. Every middleware sitting between the thrower and the catcher silently loses its "after" phase. Your request log goes quiet on exactly the requests you most want logged.
If an after-phase has to run regardless — timing, metrics, releasing a connection, closing a span — use try/finally instead of a bare await next():
const logger = async (ctx, next) => {
const start = Date.now();
try {
await next();
} finally {
console.log(`${ctx.method} ${ctx.path} — ${Date.now() - start}ms`);
}
};finally runs on both the success and the failure path, and because it doesn't catch anything, the error still propagates up to errorHandler.
One subtlety: if your error handler itself throws, that error propagates to the caller of pipeline(ctx). Always catch at the pipeline call site too:
pipeline(ctx).catch((err) => {
// Last resort — the error handler itself crashed
console.error('[unhandled pipeline error]', err);
ctx.status = 500;
ctx.body = 'Internal Server Error';
});Step 5: A Mini HTTP Router
Wire the pipeline into a real Node.js HTTP server to see it working end-to-end.
Mini HTTP router
// server.js
const http = require('http');
const { compose } = require('./pipeline');
function createRouter() {
const routes = [];
function use(...middlewares) {
routes.push({ path: null, method: null, middlewares });
return router;
}
function route(method, path, ...handlers) {
routes.push({ path, method: method.toUpperCase(), middlewares: handlers });
return router;
}
function buildPipeline(ctx) {
const matched = routes.filter(({ path, method }) => {
if (path && method) {
return method === ctx.method && ctx.path.startsWith(path);
}
return true; // global middleware
});
const fns = matched.flatMap((r) => r.middlewares);
return compose(fns);
}
const router = { use, get: route.bind(null, 'GET'), post: route.bind(null, 'POST'), buildPipeline };
return router;
}
// --- Setup ---
const router = createRouter();
router.use(async (ctx, next) => {
console.log(`--> ${ctx.method} ${ctx.path}`);
await next();
console.log(`<-- ${ctx.status}`);
});
router.get('/health', async (ctx) => {
ctx.status = 200;
ctx.body = { ok: true };
});
router.get('/users', async (ctx) => {
ctx.status = 200;
ctx.body = [{ id: 1, name: 'Ada' }];
});
const server = http.createServer(async (req, res) => {
const ctx = {
method: req.method,
path: req.url.split('?')[0],
headers: req.headers,
query: Object.fromEntries(new URL(req.url, 'http://x').searchParams),
status: 404,
body: { error: 'Not Found' },
};
const pipeline = router.buildPipeline(ctx);
await pipeline(ctx).catch((err) => {
ctx.status = 500;
ctx.body = { error: err.message };
});
res.writeHead(ctx.status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(ctx.body));
});
server.listen(3000, () => console.log('Listening on :3000'));The context object (ctx) is a plain JavaScript object. Every middleware and handler mutates it. The router's job is just matching routes and concatenating their middleware arrays into a flat list before passing it to compose. There's no magic.
Full Implementation
// pipeline.js
function compose(middlewares) {
if (!Array.isArray(middlewares)) {
throw new TypeError('middlewares must be an array');
}
for (const fn of middlewares) {
if (typeof fn !== 'function') {
throw new TypeError('each middleware must be a function');
}
}
return function (ctx, finalNext) {
let calledIndex = -1;
function dispatch(i) {
if (i <= calledIndex) {
return Promise.reject(new Error('next() called multiple times'));
}
calledIndex = i;
const fn = i < middlewares.length ? middlewares[i] : finalNext;
if (!fn) return Promise.resolve();
try {
return Promise.resolve(fn(ctx, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err);
}
}
return dispatch(0);
};
}
module.exports = { compose };Testing
// test.js — node test.js
const { compose } = require('./pipeline');
// Deliberately synchronous: an `async` assert returns a promise, and an
// un-awaited rejected promise lets the run print "All tests passed" anyway.
function assert(condition, message) {
if (!condition) throw new Error(`FAIL: ${message}`);
console.log(`PASS: ${message}`);
}
async function run() {
// Execution order: before halves go down, after halves come back up
const order = [];
const pipeline = compose([
async (ctx, next) => { order.push('A:before'); await next(); order.push('A:after'); },
async (ctx, next) => { order.push('B:before'); await next(); order.push('B:after'); },
async (ctx) => { order.push('C'); },
]);
await pipeline({});
assert(
JSON.stringify(order) === JSON.stringify(['A:before', 'B:before', 'C', 'B:after', 'A:after']),
'onion execution order'
);
// Short-circuit: B blocks, C never runs
const visited = [];
const blocking = compose([
async (ctx, next) => { visited.push('A'); await next(); },
async (ctx, next) => { visited.push('B'); /* no next() */ },
async (ctx, next) => { visited.push('C'); },
]);
await blocking({});
assert(!visited.includes('C'), 'short-circuit stops downstream');
// Error propagates to first handler wrapping in try/catch
let caughtError = null;
const withError = compose([
async (ctx, next) => { try { await next(); } catch (e) { caughtError = e.message; } },
async (ctx) => { throw new Error('boom'); },
]);
await withError({});
assert(caughtError === 'boom', 'errors propagate up to wrapping middleware');
// ...and the after-phase of anything between thrower and catcher is skipped
const phases = [];
const skipped = compose([
async (ctx, next) => { try { await next(); } catch (e) { phases.push('caught'); } },
async (ctx, next) => { phases.push('mid:before'); await next(); phases.push('mid:after'); },
async () => { throw new Error('boom'); },
]);
await skipped({});
assert(
JSON.stringify(phases) === JSON.stringify(['mid:before', 'caught']),
'a throw skips the after-phase of intervening middleware'
);
// next() called twice is caught
let doubleNextError = null;
const double = compose([
async (ctx, next) => { await next(); await next(); },
async () => {},
]);
try {
await double({});
} catch (e) {
doubleNextError = e.message;
}
assert(doubleNextError?.includes('multiple times'), 'double next() throws');
// Empty pipeline resolves
const empty = compose([]);
await empty({});
assert(true, 'empty pipeline resolves');
// finalNext: compose can chain into another composed pipeline
const outer = compose([
async (ctx, next) => { ctx.steps = []; ctx.steps.push('outer'); await next(); },
]);
const inner = compose([
async (ctx) => { ctx.steps.push('inner'); },
]);
const ctx = {};
await outer(ctx, (c) => inner(c));
assert(JSON.stringify(ctx.steps) === JSON.stringify(['outer', 'inner']), 'chaining composed pipelines');
console.log('\nAll tests passed.');
}
run().catch(console.error);What We Skipped
Express-style error middleware — Express has a four-argument convention for error handlers: (err, req, res, next). The compose above uses the onion model instead: wrap in try/catch, handle the error yourself, done. You don't need a second function signature; you just await next() inside a try block. The four-arg pattern is a design artifact of Express's callback-era origin.
Parallel middleware — nothing stops you from calling multiple composed pipelines concurrently inside a single middleware:
const parallel = async (ctx, next) => {
await Promise.all([
analyticsMiddleware(ctx, () => Promise.resolve()),
loggingMiddleware(ctx, () => Promise.resolve()),
]);
await next();
};Pass a no-op next to each branch if they shouldn't advance the main pipeline. Useful for side effects that don't need to run sequentially.
Context typing — the ctx object is a plain JS object here. In TypeScript, you'd type it as an interface (or a generic parameter) and use module augmentation to let middleware extend it without losing type safety. Koa's TypeScript types show one way to do this.
Route parameters — the router above does startsWith matching, not :param style. Adding :id support means replacing the path check with a regex (/^\/users\/([^/]+)$/) and writing the captures into ctx.params. That's 15 more lines, not another concept.
Cancellation — once dispatch(0) is called, there's no abort. If a middleware hangs (stuck await), the pipeline hangs. Real frameworks thread an AbortSignal through ctx or add a timeout wrapper. Not hard to add, but omitted here to keep the core legible.
The compose function you just built is functionally identical to koa-compose, which ships as a 30-line npm package and runs the middleware chain of every Koa app. Now you know exactly what's inside.
Comments (0)
No comments yet. Be the first to share your thoughts!