Every runtime enters Hono the same way: it calls app.fetch(request, env, executionCtx). From there the whole lifecycle is one private method, #dispatch in src/hono-base.ts, plus the compose function in src/compose.ts. This page traces that path exactly as the code executes it.

The end-to-end flow for a matched request:

sequenceDiagram
    participant RT as Runtime (Workers, Deno, Bun, Node)
    participant F as app.fetch
    participant D as dispatch (hono-base.ts)
    participant SR as SmartRouter
    participant CP as compose
    participant H as Middleware + Handler

    RT->>F: fetch(request, env, executionCtx)
    F->>D: dispatch(request, ctx, env, method)
    D->>D: path = getPath(request)
    D->>SR: match(method, path)
    SR-->>D: [[handler, paramIndexMap][], paramStash]
    D->>CP: compose(handlers, onError, onNotFound)(context)
    CP->>H: handler(c, next)
    H-->>CP: Response
    CP-->>D: finalized Context
    D-->>RT: context.res

Entry: fetch is a one-liner

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

There is no server, socket, or event loop in the framework. Whatever hands you a Web Standard Request -- a Workers fetch event, Deno.serve, Bun.serve, or @hono/node-server translating Node's HTTP objects -- can drive a Hono app.

Dispatch: match once, build one Context

src/hono-base.ts
#dispatch(request, executionCtx, env, method) {
  // Handle HEAD method
  if (method === 'HEAD') {
    return (async () =>
      new Response(null, await this.#dispatch(request, executionCtx, env, 'GET')))()
  }

  const path = this.getPath(request, { env })
  const matchResult = this.router.match(method, path)

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

Three things worth noticing:

  • HEAD is synthesized from GET. Hono dispatches the request as GET and wraps the result in a body-less Response, so you never write HEAD handlers.
  • getPath is pluggable. By default it is a hand-optimized string scan over request.url (src/utils/url.ts) that avoids constructing a URL object; the strict: false and getPath options swap it (see Configuration).
  • The router returns every matching handler at once -- middleware and the final handler together, in registration order, with parameter indexes. The match result is handed to Context so c.req.param() can resolve parameters lazily.

The single-handler fast path

If exactly one handler matched (no middleware anywhere on the path), Hono skips compose entirely:

src/hono-base.ts
// 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)
  }
  // …
}

This is a deliberate hot-path optimization: a plain app.get('/', handler) app never pays the cost of the middleware machinery.

The composed path

With more than one handler, compose chains them koa-style and the result must be a finalized Context:

src/hono-base.ts
const composed = compose(matchResult[0], this.errorHandler, this.#notFoundHandler)

return (async () => {
  try {
    const context = await composed(c)
    if (!context.finalized) {
      throw new Error(
        'Context is not finalized. Did you forget to return a Response object or `await next()`?'
      )
    }
    return context.res
  } catch (err) {
    return this.#handleError(err, c)
  }
})()

That error message is the one you see when a middleware neither returns a Response nor awaits next(). How compose walks the chain -- including where onError and onNotFound fire -- is covered in Middleware and compose.

Errors and 404s

Both fallbacks are plain handlers defined at the top of hono-base.ts, replaceable via app.notFound() and app.onError():

src/hono-base.ts
const notFoundHandler: NotFoundHandler = (c) => {
  return c.text('404 Not Found', 404)
}

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)
}

The 'getResponse' in err check is what makes HTTPException work without an instanceof dependency -- any thrown object carrying a getResponse() method turns into its own Response. Recipes for both hooks are in Errors and not-found responses.

Sources: src/hono-base.ts, src/compose.ts · last synced 2026-08-10 · 26de731 · version 4.13.1