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
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
#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. getPathis pluggable. By default it is a hand-optimized string scan overrequest.url(src/utils/url.ts) that avoids constructing aURLobject; thestrict: falseandgetPathoptions 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
Contextsoc.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:
// 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:
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():
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.