The Request Lifecycle — From fetch() to Response
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:
- Clean path (no
%,?, or#): Returns a simple slice — zero allocations beyond the substring - Path with query/hash: Stops at
?(charCode 63) or#(charCode 35) - Percent-encoded path: Falls back to
indexOf()scanning and appliestryDecodeURI()
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
getPathoptimization matters most in high-throughput scenarios. On Cloudflare Workers benchmarks, avoidingnew 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():
- Handler exists at index
i: Call it with(context, () => dispatch(i + 1)). If it throws, invokeonErrorif available. - Past the end of middleware but
nextwas provided: Callnext()— this supports sub-app composition. - 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.routeIndexdetermines which handler's params are returned. Middleware at index 0 might have{}params while the route handler at index 2 has{ id: '123' }. TherouteIndexis updated bycompose()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:
fetch()receivesRequest,env,executionCtx; delegates to#dispatch()#dispatch()checks for HEAD method (dispatches as GET, strips body)getPath()extracts the URL path via character-code scanningrouter.match()returns matched handlers with parametersContextcreated with request, path, match result, env, execution context- Single-handler fast path or
compose()invokes the middleware chain - Handler calls
c.text(),c.json(), etc. to build the response - 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.