Every handler receives a Context (c), built once per request in #dispatch. Context (src/context.ts) owns the response side and the per-request variable map; HonoRequest (src/request.ts) wraps the raw Request with parameter and body helpers. Both are engineered around one idea: do nothing until a handler actually asks.
Laziness is the design
c.req does not exist until the first access -- the getter constructs it on demand from the raw request and the router's match result:
get req(): HonoRequest<P, I['out']> {
this.#req ??= new HonoRequest(this.#rawRequest, this.#path, this.#matchResult)
return this.#req
}
The same pattern repeats across the class: the #var map behind c.set()/c.get() is allocated on first set, response headers (#preparedHeaders) on first c.header() call, and the default renderer only when c.render() is used.
The most aggressive case is c.text(): if nothing about the response has been customized, it skips Hono's own response assembly entirely and returns a bare Response:
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)) as ReturnType<TextRespond>)
}
Parameters resolve against the match result
c.req.param('id') does not read from a params object built at match time. RegExpRouter returns a ParamIndexMap (name to capture-group index) plus the raw match array as a stash; HonoRequest resolves and URL-decodes on demand, using routeIndex -- which compose updates at every layer -- to pick the right route's map:
#getDecodedParam(key: string): string | undefined {
const paramKey = this.#matchResult[0][this.routeIndex][1][key]
const param = this.#getParamValue(paramKey)
return param && tryDecodeURIComponent(param)
}
#getParamValue(paramKey: any): string | undefined {
return this.#matchResult[1] ? this.#matchResult[1][paramKey as any] : paramKey
}
The this.#matchResult[1] branch is the stash-indexed form (RegExpRouter); without a stash, paramKey already is the value (TrieRouter and the linear routers). This is the Result<T> duality described in Routing.
Body reading is cached and convertible
A Web Standard body stream can be read once. HonoRequest keeps a bodyCache so c.req.json() in a validator and c.req.text() in a handler both work -- and if a different representation was cached first, it converts through a throwaway Response rather than touching the consumed stream:
#cachedBody = (key: keyof Body) => {
const { bodyCache, raw } = this
const cachedBody = bodyCache[key]
if (cachedBody) {
return cachedBody
}
for (const anyCachedKey in bodyCache) {
return (bodyCache[anyCachedKey as keyof Body] as Promise<BodyInit>).then((body) => {
if (anyCachedKey === 'json') {
body = JSON.stringify(body)
}
return new Response(body)[key]()
})
}
return (bodyCache[key] = raw[key]())
}
Setting c.res merges, not replaces
Assigning c.res when a response already exists (typical in middleware that swaps the response after await next()) rebuilds the Response but carries existing headers over -- with set-cookie appended rather than overwritten, and content-type deliberately left to the new response:
set res(_res: Response | undefined) {
if (this.#res && _res) {
_res = createResponseInstance(_res.body, _res)
for (const [k, v] of this.#res.headers.entries()) {
if (k === 'content-type') {
continue
}
if (k === 'set-cookie') {
const cookies = this.#res.headers.getSetCookie()
_res.headers.delete('set-cookie')
for (const cookie of cookies) {
_res.headers.append('set-cookie', cookie)
}
} else {
_res.headers.set(k, v)
}
}
}
this.#res = _res
this.finalized = true
}
The last line matters most: assigning c.res is what finalizes a context. compose and #dispatch both key off c.finalized to decide between "response ready" and "fall through to notFound" -- see Middleware and compose.
Runtime bindings
c.env carries whatever the runtime passed as the second argument to fetch (Workers bindings, Lambda event context via the adapter). c.executionCtx exposes the Workers ExecutionContext (waitUntil, passThroughOnException) and throws a plain Error('This context has no ExecutionContext') on runtimes that do not provide one -- the framework does not fake it.