Web Framework from Scratch· Capstone
Capstone · The Complete Mental Model

It was never magic

Six chapters grew a framework from a single contract. This capstone strips away the syntax and keeps only the ideas that carry the weight: one handler type, one loop that folds a request into a response, and the one design choice that turns a router into a middleware pipeline. Then we find where this toy stops and Hono begins.

You started with fetch: () => new Response('Hello!') and finished with routing, a context object, middleware, helpers, and full TypeScript generics. Every chapter added one idea. Put them together and the surprise is how little is actually there: a list of routes, and a loop that walks it. The lab below is that loop — run a request through the exact pipeline from Chapter 6 and watch a response accumulate.

Lab · run the loop

Fold a request through the pipeline, hop by hop

The same four registrations from the book: two */* middleware bracketing two real routes. Pick a request and step it. Then flip lazy context on and re-send GET /api/users/123 to watch a subtle bug appear.

Step 0 — press Step to begin
ReadyPick a request, then walk the route loop one entry at a time.
requestroutemethodpatternparamseffect
res(none yet)headersvars{}routes run0
final response
01

One contract, one handler

The runtime asks for one function: take a Request, give back a Response. Everything else is bookkeeping on the way between them — and all of that bookkeeping has a single shape.

The deepest simplification in the whole book is one you might have missed: there is no Middleware type. A route handler and a piece of middleware are the same shape. Both are just a function of the context:

type Handler = (c: Context) => Response | void

A handler that returns a Response is a responder. A handler that returns nothing is an observer or mutator — it reads the request, writes to c.vars, or tweaks c.res, then steps aside. "Route" and "middleware" are not two mechanisms; they are two uses of one mechanism:

// a responder — returns a Response
app.on('GET', '/', (c) => c.helper('html', '<h1>Home</h1>'))

// a middleware — returns nothing, just sets a variable
app.on('*', '*', (c) => { c.vars.user = 'Alice' })
Route = middleware = handler

One type collapses three concepts. Once you see it, the framework stops being "a router plus a middleware system" and becomes "a list of functions, some of which answer."

02

fetch is a fold — and the pivot that created middleware

The fetch method is a reduce. Start with no response; for each matching route, run it and let it maybe replace the running response; end with res ?? 404.

async fetch(req: Request) {
  let res: Response | undefined            // the accumulator
  for (const { method, pattern, handler } of routes) {
    const match = pattern.exec(req.url)
    if (match && (method === '*' || method === req.method)) {
      const out = await handler({ req, match, res, vars })
      if (out instanceof Response) res = out   // fold step
    }
  }
  return res ?? new Response('Not Found', { status: 404 })
}

Now the part worth slowing down for. In Chapter 2 this loop short-circuited the moment a handler returned a response:

// Chapter 2 — "first response wins"
const res = handler(ctx)
if (res) return res            // ← early return

By Chapter 4 that early return was simply deleted. That one deletion is the entire difference between a router and a middleware pipeline. Here is why it matters more than it looks: middleware that runs before a responder already worked in Chapter 2 (a void-returning handler doesn't trigger the early return). What could not work was middleware that runs after the responder — because the responder's return skipped everything below it. Removing the early return is what lets a trailing */* handler post-process the response.

Delete the early return → you get post-response middleware

First-response-wins can run "before" middleware but never "after" middleware. Run-all runs everything that matches, so a response-timing or CORS header can be added after the route answers. The trailing timer in the lab depends entirely on this.

Think first

In the lab pipeline, the last entry is a */* middleware that adds an X-Response-Time header. Under Chapter 2's first-response-wins loop, would that header ever get added for GET /?

Answer · no, never

The home route returns a Response, which triggers the early return — so the loop ends and the trailing timer middleware is never reached. Only the run-all model (no early return) lets it run after the responder and attach the header. Step GET / in the lab to see the timer run as the last hop.

03

The shared-context sharp edge

The book's final version creates the context once and reuses it across all matching routes. Elegant for threading vars and res — but it freezes match, and that bites.

Look closely at the Chapter 6 loop. The context is built lazily with ??=, then the same object is spread into every matching handler:

for (const { m, p, h } of routes) {
  const match = p.exec(req.url)
  if (match && (req.method === m || m === '*')) {
    context ??= { env, executionContext, match, vars, req }  // ← assigned ONCE
    const response = await h({ ...context, helper })
    if (response instanceof Response) context.res = response
  }
}

match is recomputed every iteration, but context ??= { ...match } only stores it the first time a route matches. After that, context.match is frozen. Now picture the lab pipeline: the very first match is the */* timer middleware, so context.match becomes the wildcard's match. When GET /api/users/:id runs next, its handler reads c.match.pathname.groups — but that's still the wildcard's groups, an unnamed capture like { "0": "/api/users/123" }. So:

const { id } = c.match.pathname.groups   // id is undefined, not "123"
This is the Mutation chapter in disguise

One object, created once, read by many handlers, with a field that's stale for everyone after the first. That's "spooky action at a distance" wearing a server costume. The shared vars reference is the intended version of this sharing; the frozen match is the accidental version.

The fix is to build a fresh context per matching route — so match is always this route's — while still threading the shared vars reference and the running res:

async fetch(req, env = {}, executionContext) {
  let res: Response | undefined
  for (const { method, pattern, handler } of routes) {
    const match = pattern.exec(req.url)
    if (!match) continue
    if (method !== '*' && method !== req.method) continue

    const context: Context = { req, res, match, vars, env, executionContext }
    const out = await handler({
      ...context,
      helper: (name, ...args) => helpers[name]?.(context, ...args),
    })
    if (out instanceof Response) res = out
  }
  return res ?? new Response('Not Found', { status: 404 })
}

vars stays a single shared object, so writes by one handler are visible to the next (intended sharing). res is threaded through the local accumulator and re-read into each fresh context. match is now correct for every route. Here is the whole corrected framework, types included:

type Method = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD' | '*'

type Context = {
  req: Request
  res?: Response
  match: URLPatternResult
  vars: Record<string, unknown>
  env?: unknown
  executionContext?: unknown
}

type Helper = (c: Context, ...args: any[]) => unknown

// drop the leading `context` arg when calling through c.helper(...)
type DropFirst<T extends unknown[]> = T extends [unknown, ...infer R] ? R : []

type CtxWith<H extends Record<string, Helper>> = Context & {
  helper: <K extends keyof H>(name: K, ...args: DropFirst<Parameters<H[K]>>) => ReturnType<H[K]>
}

type Handler<H extends Record<string, Helper> = {}> =
  (c: CtxWith<H>) => Response | Promise<Response> | void

type Route = { method: string; pattern: URLPattern; handler: Handler<any> }

export function createApp<H extends Record<string, Helper> = {}>() {
  const routes: Route[] = []
  const helpers = {} as H
  const vars: Record<string, unknown> = {}

  const app = {
    on(method: Method, path: string, handler: Handler<H>) {
      routes.push({ method: method.toUpperCase(), pattern: new URLPattern({ pathname: path }), handler })
      return app
    },
    setHelper(name: string, helper: Helper) {
      helpers[name] = helper
      return app
    },
    async fetch(req: Request, env: unknown = {}, executionContext?: unknown) {
      let res: Response | undefined
      for (const { method, pattern, handler } of routes) {
        const match = pattern.exec(req.url)
        if (!match) continue
        if (method !== '*' && method !== req.method) continue
        const context: Context = { req, res, match, vars, env, executionContext }
        const out = await handler({ ...context, helper: (n, ...a) => helpers[n]?.(context, ...a) } as CtxWith<H>)
        if (out instanceof Response) res = out
      }
      return res ?? new Response('Not Found', { status: 404 })
    },
  }
  return app
}
04

Conveyor belt vs onion: where Hono begins

This framework is a conveyor belt — handlers run front to back, each may overwrite the response, none can wrap the others. Production frameworks use an onion, and that one change is the gap between your toy and Hono.

Conveyor belt — this frameworkOnion — Hono / Koa / Expressreqmw 1beforerouteanswersmw 2afterresEach box runs once, in registration order."After" work only happens because mw 2 isregistered last. No box brackets the others.res = run(mw1); run(route); run(mw2) // a flat looproutemw 1mw 2beforeafterEach mw runs code, calls await next() to godeeper, then runs code on the way back out.

On the conveyor belt, a handler can only act at its own position. "After" work — timing, CORS, error catching, closing a transaction — only happens because you registered that middleware last. No single function can run code both before and after the route.

The onion gives each middleware a next function. It runs some code, calls await next() to invoke everything deeper (eventually the route), then runs more code on the way back out. One function brackets the entire downstream — which is exactly what you need for timing, error boundaries, and cleanup. Evolving the toy is a small structural change: replace the flat loop with recursion.

// from a flat loop to an onion: recursive dispatch with next()
async fetch(req: Request) {
  const matched = routes.filter((r) => r.pattern.test(req.url) && methodOk(r, req))
  let res: Response | undefined

  const dispatch = async (i: number): Promise<void> => {
    const r = matched[i]
    if (!r) return
    const next = () => dispatch(i + 1)        // go deeper
    const out = await r.handler({ req, vars, res, next })
    if (out instanceof Response) res = out
  }

  await dispatch(0)
  return res ?? new Response('Not Found', { status: 404 })
}

// now one middleware can wrap the whole rest of the chain:
app.on('*', '*', async (c) => {
  const start = Date.now()
  await c.next()                              // run everything deeper first
  c.res?.headers.set('X-Response-Time', `${Date.now() - start}ms`)
})

That is essentially Hono's model. The pieces you already built map straight across: Hono's c is your context, c.json() / c.html() are your helper system standardized, and app.use() registers onion middleware while app.get() registers routes. What Hono adds on top is a trie/radix router (so matching isn't a linear scan), proper next() onion composition, typed routes, and batteries like body parsing and validators.

You already built Hono's skeleton

Context object, handler-as-function, response accumulation, helpers. Add next()-based composition and a faster router, and the toy becomes the real thing. The mental model does not change — only the machinery underneath it.

05

The whole model, in six lines

  • One contract underlies everything: fetch(req) returns a Response.
  • One handler type unifies routes and middleware: (c: Context) => Response | void. Returning a response means you answered; returning nothing means you observed or modified.
  • fetch is a fold over the matching routes, accumulating res and ending with res ?? 404.
  • Deleting the early return turns first-response-wins into run-all — the change that lets middleware run after a responder.
  • A single reused context is fast but freezes match; build a fresh context per route while sharing vars and threading res.
  • This is a conveyor belt; add next() to make it an onion, and a faster router, and you have arrived at Hono.
The point of building it

The "magic" of frameworks is careful engineering over a small platform contract. Now when you reach for Hono, you can see the loop behind app.get, the fold behind the response, and the onion behind app.use — and debug all three.