Read OSS

The Request Lifecycle — From fetch() to Response

Intermediate

Prerequisites

  • Article 1: Architecture Overview and Code Navigation
  • Understanding of async/await and Promises
  • Familiarity with the Koa middleware onion model (helpful but explained)

The Request Lifecycle — From fetch() to Response

Every Hono application ultimately receives a Request and returns a Response. Whether you're running on Cloudflare Workers, Deno, Bun, or Node.js, the entry point is the same: app.fetch(request, env, executionCtx). What happens between those two points — URL parsing, route matching, context creation, middleware composition, response building, and error handling — is the subject of this article.

We'll trace a single request through the entire pipeline, highlighting the performance optimizations that make Hono fast and the design decisions that keep it clean. By the end, you'll be able to follow any request through the framework with confidence.

The fetch() Entry Point and #dispatch()

The universal entry point is fetch(), defined as a property on HonoBase:

fetch: (
  request: Request,
  Env?: E['Bindings'] | {},
  executionCtx?: ExecutionContext
) => Response | Promise<Response> = (request, ...rest) => {
  return this.#dispatch(request, rest[1], rest[0], request.method)
}

This signature is deliberately compatible with multiple runtimes. Cloudflare Workers pass (request, env, ctx), Deno and Bun pass just (request), and the adapter layer for Node.js converts IncomingMessage/ServerResponse to Request/Response before calling fetch().

All roads lead to #dispatch(), the private method that is the critical path for every request.

sequenceDiagram
    participant Runtime as Runtime (CF/Deno/Bun)
    participant Fetch as app.fetch()
    participant Dispatch as #dispatch()
    participant Router as router.match()
    participant Compose as compose() / fast path
    participant Context as Context → Response

    Runtime->>Fetch: Request, env, ctx
    Fetch->>Dispatch: request, executionCtx, env, method
    Dispatch->>Dispatch: getPath(request)
    Dispatch->>Router: match(method, path)
    Router-->>Dispatch: [[handlers, params], stash]
    Dispatch->>Compose: handlers + error/notFound
    Compose->>Context: c.text() / c.json() / c.html()
    Context-->>Runtime: Response

URL Path Extraction Without new URL()

Before routing can happen, Hono needs the request path. The obvious approach — new URL(request.url).pathname — has a well-known performance cost: the URL constructor parses the entire URL including query strings, fragments, protocol, and authority. For a framework that aims to add minimal overhead per request, this is unacceptable.

Instead, Hono uses getPath(), a hand-written character-code scanner:

export const getPath = (request: Request): string => {
  const url = request.url
  const start = url.indexOf('/', url.indexOf(':') + 4)
  let i = start
  for (; i < url.length; i++) {
    const charCode = url.charCodeAt(i)
    if (charCode === 37) {      // '%' — percent encoding detected
      // Fall back to indexOf for '?' and '#'
      const queryIndex = url.indexOf('?', i)
      const hashIndex = url.indexOf('#', i)
      // ... extract and decode path
    } else if (charCode === 63 || charCode === 35) {  // '?' or '#'
      break
    }
  }
  return url.slice(start, i)
}

The function finds the start of the path by skipping past :// and the host, then scans character by character. It handles three cases:

  1. Clean path (no %, ?, or #): Returns a simple slice — zero allocations beyond the substring
  2. Path with query/hash: Stops at ? (charCode 63) or # (charCode 35)
  3. Percent-encoded path: Falls back to indexOf() scanning and applies tryDecodeURI()
flowchart TD
    A["request.url = 'https://example.com/api/users?page=1'"] --> B["Find start: indexOf('/', indexOf(':') + 4)"]
    B --> C["Scan char codes from start"]
    C --> D{charCode?}
    D -->|37 '%'| E["Percent-encoded: indexOf fallback + tryDecodeURI"]
    D -->|63 '?' or 35 '#'| F["Stop scanning"]
    D -->|Other| G["Continue scanning"]
    F --> H["Return url.slice(start, i) → '/api/users'"]
    G --> C

When strict mode is disabled, getPathNoStrict() strips trailing slashes so /hello/ and /hello match the same route.

Tip: The getPath optimization matters most in high-throughput scenarios. On Cloudflare Workers benchmarks, avoiding new URL() can save 1–2 microseconds per request — significant when your total request handling budget is 10–50μs.

Route Matching and the Single-Handler Optimization

With the path extracted, #dispatch() calls this.router.match(method, path) to find matching handlers. The match result is a tuple containing an array of [handler, params] pairs (we'll explore the dual result format in Part 3).

The critical optimization happens at src/hono-base.ts#L424-L442:

// Do not `compose` if it has only one handler
if (matchResult[0].length === 1) {
  let res: ReturnType<H>
  try {
    res = matchResult[0][0][0][0](c, async () => {
      c.res = await this.#notFoundHandler(c)
    })
  } catch (err) {
    return this.#handleError(err, c)
  }

  return res instanceof Promise
    ? res
        .then(
          (resolved: Response | undefined) =>
            resolved || (c.finalized ? c.res : this.#notFoundHandler(c))
        )
        .catch((err: Error) => this.#handleError(err, c))
    : (res ?? this.#notFoundHandler(c))
}

When only one handler matches — which is the common case for simple routes without middleware — Hono calls it directly with inline error handling. This completely skips the compose() machinery. The next callback passed to the handler invokes the not-found handler, which is the correct behavior for a terminal handler that doesn't call next().

The handler return value is checked: if it's a Promise, .then() and .catch() handle async resolution and errors. If synchronous, the result is returned immediately. This dual sync/async handling avoids creating unnecessary Promise wrappers.

flowchart TD
    M["matchResult[0].length"] --> CHECK{=== 1?}
    CHECK -->|Yes| FAST["Direct invocation<br/>handler(c, notFoundNext)"]
    CHECK -->|No| COMPOSE["compose(matchResult[0],<br/>errorHandler, notFoundHandler)"]
    FAST --> SYNC{Is Promise?}
    SYNC -->|No| RET["Return res directly"]
    SYNC -->|Yes| AWAIT["res.then().catch()"]
    COMPOSE --> EXEC["await composed(c)"]
    EXEC --> FIN{"c.finalized?"}
    FIN -->|Yes| RETURN["return c.res"]
    FIN -->|No| ERROR["throw Error('Context not finalized')"]

Middleware Composition with compose()

When multiple handlers match (middleware + route handler), #dispatch() delegates to compose(). This 73-line function implements the Koa-style onion model:

export const compose = <E extends Env = Env>(
  middleware: [[Function, unknown], unknown][] | [[Function]][],
  onError?: ErrorHandler<E>,
  onNotFound?: NotFoundHandler<E>
): ((context: Context, next?: Next) => Promise<Context>) => {
  return (context, next) => {
    let index = -1
    return dispatch(0)

    async function dispatch(i: number): Promise<Context> {
      if (i <= index) {
        throw new Error('next() called multiple times')
      }
      index = i
      // ... handler resolution, error handling, not-found detection
    }
  }
}

The recursive dispatch(i) function is the engine. Each middleware receives (context, next) where next = () => dispatch(i + 1). The index guard prevents calling next() more than once per middleware — a common source of bugs that Hono catches explicitly.

Three cases are handled inside dispatch():

  1. Handler exists at index i: Call it with (context, () => dispatch(i + 1)). If it throws, invoke onError if available.
  2. Past the end of middleware but next was provided: Call next() — this supports sub-app composition.
  3. No handler and context is not finalized: Call onNotFound.

The routeIndex property on context.req is updated at each step so that c.req.param() can extract the correct parameters from the matched route — different middleware may have different parameter maps.

flowchart LR
    subgraph Onion["Middleware Onion"]
        MW1["Middleware 1<br/>before next()"] --> MW2["Middleware 2<br/>before next()"]
        MW2 --> H["Route Handler<br/>c.json(data)"]
        H --> MW2B["Middleware 2<br/>after next()"]
        MW2B --> MW1B["Middleware 1<br/>after next()"]
    end
    REQ["Request"] --> MW1
    MW1B --> RES["Response"]

Context Creation and Response Building

The Context class wraps the entire request-response lifecycle. It's created in #dispatch() and passed through the middleware chain:

const c = new Context(request, {
  path,
  matchResult,
  env,
  executionCtx,
  notFoundHandler: this.#notFoundHandler,
})

Lazy HonoRequest: The c.req property uses a getter that lazily creates a HonoRequest on first access (line 366–369). If a middleware only reads c.env or sets headers without touching the request body, the HonoRequest is never constructed.

Response builders: Context provides c.text(), c.json(), c.html(), and c.body(). Each one is a thin wrapper around #newResponse() that sets the appropriate Content-Type header. But c.text() has a fast path:

text: TextRespond = (text, arg, headers) => {
  return !this.#preparedHeaders && !this.#status && !arg && !headers && !this.finalized
    ? (new Response(text) as ReturnType<TextRespond>)
    : (this.#newResponse(text, arg, setDefaultContentType(TEXT_PLAIN, headers)))
}

When no custom headers, status, or extra arguments are set — which is extremely common — c.text() creates a bare new Response(text), bypassing all header merging logic. The Response constructor's default Content-Type for string bodies is text/plain;charset=UTF-8, which is exactly what we need.

Header merging in the res setter: When middleware sets headers before the handler runs (e.g., CORS headers), those headers need to be preserved on the final response. The res setter handles this by copying headers from the existing response to the new one, with special handling for set-cookie (which must be appended, not overwritten) and content-type (which should come from the new response).

Error Handling and HEAD Requests

Hono provides structured error handling through HTTPException:

export class HTTPException extends Error {
  readonly res?: Response
  readonly status: ContentfulStatusCode

  getResponse(): Response {
    if (this.res) {
      return new Response(this.res.body, {
        status: this.status,
        headers: this.res.headers,
      })
    }
    return new Response(this.message, { status: this.status })
  }
}

When an HTTPException is thrown, the default error handler (defined at the top of hono-base.ts) detects the getResponse method and uses it:

const errorHandler: ErrorHandler = (err, c) => {
  if ('getResponse' in err) {
    const res = err.getResponse()
    return c.newResponse(res.body, res)
  }
  console.error(err)
  return c.text('Internal Server Error', 500)
}

This duck-typing check ('getResponse' in err) means any error class with a getResponse() method works — not just HTTPException. Custom error classes can integrate seamlessly.

HEAD requests receive special treatment at the top of #dispatch():

if (method === 'HEAD') {
  return (async () =>
    new Response(null, await this.#dispatch(request, executionCtx, env, 'GET')))()
}

HEAD requests are dispatched as GET, then the body is stripped by creating a new Response(null, ...) that copies only the status and headers from the GET response. This means your GET handlers don't need to know about HEAD — the framework handles it transparently.

The HonoRequest Wrapper

HonoRequest wraps the native Request with convenience methods and handles the dual result format from the router. The most interesting aspect is how param() works:

#getParamValue(paramKey: any): string | undefined {
  return this.#matchResult[1] ? this.#matchResult[1][paramKey as any] : paramKey
}

This single line handles both result formats. When the router returns [[handler, ParamIndexMap][], ParamStash] (RegExpRouter's format), paramKey is a numeric index into the stash array, and this.#matchResult[1] is the stash. When the router returns [[handler, Params][]] (other routers' format), this.#matchResult[1] is undefined, so paramKey — which is already the string value — is returned directly. This elegant unification avoids conditional branching per router type.

Tip: When debugging route parameters, remember that c.req.routeIndex determines which handler's params are returned. Middleware at index 0 might have {} params while the route handler at index 2 has { id: '123' }. The routeIndex is updated by compose() as it walks through the middleware chain.

Summary: The Complete Request Path

Putting it all together, here's the full path of a request through Hono:

  1. fetch() receives Request, env, executionCtx; delegates to #dispatch()
  2. #dispatch() checks for HEAD method (dispatches as GET, strips body)
  3. getPath() extracts the URL path via character-code scanning
  4. router.match() returns matched handlers with parameters
  5. Context created with request, path, match result, env, execution context
  6. Single-handler fast path or compose() invokes the middleware chain
  7. Handler calls c.text(), c.json(), etc. to build the response
  8. Response is returned to the runtime

In the next article, we'll dive deep into the five router implementations — how RegExpRouter compiles all routes into a single regex, why LinearRouter does zero work at registration time, and how SmartRouter orchestrates the fallback strategy.