neerajkumar.me

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:

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.

← All writing