What rebuilding Express taught me about Express
I had used Express for years before I could have told you what next() actually
does. Not vaguely — precisely. So I
wrote a small framework
that reimplements the parts I used most, to find out.
Three things stopped being magic.
req and res are just streams
Express does not hand you a request object it invented. req is an
http.IncomingMessage, which is a readable stream. res is an
http.ServerResponse, a writable one. Everything Express adds — req.body,
res.json() — is a convenience layered on top.
That reframing is worth more than it sounds. req.body does not exist until
something reads the stream to the end and parses it, which is exactly why
body-parser is middleware and why req.body is undefined when you forget
it. Once you have written the two-line version yourself, that class of bug stops
being confusing:
const raw = await new Promise<string>((resolve) => {
let data = "";
req.on("data", (c) => (data += c));
req.on("end", () => resolve(data));
});
You also see why streaming a large response works and buffering it does not —
res was a stream the whole time.
Middleware is a linked list you walk manually
The middleware chain looks like framework machinery. It is an array and an index:
function run(i: number) {
const fn = stack[i];
if (!fn) return;
fn(req, res, () => run(i + 1));
}
That is next(). It is a callback that advances the index. Which explains
things that used to feel arbitrary:
- Forgetting
next()hangs the request, because nothing advances the index and nothing responds. The request is not “lost” — it is sitting at positioniforever. - Calling
next()twice runs the rest of the chain twice. - Registration order is execution order, because it is literally array order.
- Error middleware needs a different arity so the runner can tell the two kinds apart while walking the same list.
None of that is a rule to memorise once you have seen the loop.
Routing is pattern matching, and the sequence matters
A route is a method, a pattern and a handler. Matching /users/:id means
turning the pattern into something you can test a path against and pulling the
named segments out. The interesting part is not the matching — it is that
routes are checked in registration order, so a greedy route registered early
shadows a specific one registered later.
I had hit that bug in production before. Writing the matcher is what made me understand it rather than just remember it.
Was it worth it?
The framework is not a serious Express replacement and was never meant to be. It has no error handling worth the name, no router composition, none of the hardening that makes the real thing safe to run.
But I debug Node services differently now. When something behaves oddly at the HTTP layer, I have a model of what is underneath rather than a list of remembered behaviours. That transfers to every framework built on the same primitives — which, in Node, is most of them.
If you want to try it: skip the tutorials and reimplement the twenty percent of a tool you actually use. The gap between “I can use this” and “I know what this does” closes fast.