Status
Building
Role
Author
Type
Personal · learning build
Year
2025–2026
Stack
Node.js · TypeScript · HTTP

Personal learning build. These notes describe a design. They are not production metrics, client work, or a claim about users.

Context

I have used backend frameworks for years. I wanted the part they are polite about. What happens between a socket and the function I think of as the route. This build is that, and almost nothing else.

No Express. No claim that the result should replace one. The constraint is that the whole core stays small enough to read in a sitting, and that every convenience has a place I can point to.

Constraints

Node's http module is the only server. If a behavior is not visible there, it does not get to hide in a dependency.

Routing, middleware, a request context, a response helper, and errors. Static files, validation libraries, and an ORM are out of scope until those five are honest.

The body of a request is a stream until some middleware decides to read it. Reading it twice is a bug, not a feature.

Errors a handler expects are different from errors nobody expected. The client should be able to tell which one it got. The client should not get a stack trace as a consolation prize.

Architecture

One request has a lifetime. The framework is the set of rules for that lifetime.

Lifetime of one request
  1. 01Node HTTPThe raw request and response. Nothing is wrapped yet.
  2. 02ContextMethod, URL, headers, params, and a body that is still a stream.
  3. 03MiddlewareAn onion. Each function may pass control on, or finish the response.
  4. 04RouterMethod plus a path pattern, compiled once. First match wins.
  5. 05HandlerReturns a result, or throws an error that already knows its status.
  6. 06Error boundaryKnown errors become responses. Unknown ones become a quiet 500.

Nothing in that list is novel. The exercise is to make each box do one thing, and to notice which box people usually blur.

Decisions

A path pattern such as /users/:id is compiled to a regular expression once, when the route is registered. Matching at request time is a test, not another parse. Named groups become params.

First match wins. Overlapping routes are an ordering problem, and the order is the order of registration. A trie would be faster and harder to read. At this size, linear matching is the honest algorithm.

Middleware uses the shape (ctx, next) => Promise. I kept that shape because it is the idea being studied, not because it is sacred. next has to be awaited. If it is not, the function after it races the function before it, and two of them try to finish one response.

The handler returns a small result, status plus body, or it throws an HttpError that already carries a status. Returning is the calm path. Throwing is how a handler refuses without inventing a second way to write the response.

Body parsing is middleware, and it is opt-in. JSON is not a property of HTTP. A route that does not need a body should not pay for one, and should not discover that the stream was already consumed by a default it did not ask for.

The error boundary sits outside the handler. A known HttpError becomes its status and a short message. Anything else becomes a 500 with a generic body. The stack stays in the server log.

Data flow

http.createServer receives the raw request and response. The framework builds a context: method, URL, headers, a placeholder for params, and the body stream still unread.

The middleware chain runs from the outside in. Each function may modify the context, set a header, or decide that it is done. If it calls next, it waits. When the chain reaches the router, the router tests method and path. No match is a 404, which is just an HttpError like any other.

A match fills params and calls the handler. The handler's result is written once. Status, headers, body, end. If the handler throws, the boundary writes instead. In both cases, one owner finishes the response.

Tradeoffs

Linear routing will not win a benchmark against a radix tree. It will still be obvious when two patterns overlap. I would rather see the overlap.

The Express-shaped middleware contract is full of historic traps, including the one where a function both writes and calls next. I kept the contract so I would meet the trap on purpose, and then made a double write a thrown error instead of a hung request.

Hiding the stream behind ctx.body as an already-parsed value is convenient and slightly dishonest. The raw stream stays on the context. The JSON helper is a function that reads it, once.

There is no automatic HEAD handling in the first cut. HEAD is GET without a body. Forgetting that is a good way to learn why frameworks special-case it. It is on the list, not silently done.

Problems

The first bug was a response that never ended. The handler returned, the function that was supposed to write the result assumed someone else would, and the client waited. "Who is allowed to call end" is the actual core of the framework. Everything else is manners.

The second bug was middleware that sent a response and also called next. Both paths wrote. The fix is a flag on the context: after the response is finished, further writes throw. Politeness is not enough. The type of mistake has to be loud.

Query strings and path patterns look similar until they are not. The path used for matching is the pathname only. The query stays a separate parse. Mixing them makes /users/:id look like it matched /users/1?id=2 for the wrong reason.

Async errors inside a listener that is not awaited disappear. The chain is a promise. The server callback catches that promise. If it does not, a throw becomes an unhandledRejection and the client still hangs.

What I learned

A framework is a convention about the lifetime of a request, plus a pile of defaults people forget are optional.

The hard part is not routing. The hard part is deciding who may write the response, and what happens when two pieces of code both think they may.

Reading a small version makes the large ones less magical. The magic was mostly agreement.

Next iteration

A test harness that does not need an open port. Build a request-shaped object, run the chain, assert on the result. The network can stay out of the unit test.

Then HEAD, and a static-file middleware that is obviously middleware, not a special case in the core.