From Request to Response: The Dispatch Pipeline and Middleware Composition
Prerequisites
- ›Article 1: The Architecture of Hono
- ›Understanding of async/await and Promises in JavaScript
- ›Familiarity with the Koa/Express middleware pattern (onion model)
From Request to Response: The Dispatch Pipeline and Middleware Composition
Every web framework exists to answer one question: given a Request, produce a Response. What makes Hono's answer interesting is the small number of moving parts involved. The entire dispatch path — from app.fetch() to a returned Response — spans fewer than 60 lines in #dispatch() and 73 lines in compose(). Understanding these ~130 lines gives you complete insight into how every request is handled.
Entry Point: app.fetch() and app.request()
There are two ways into Hono. The production path is fetch(), which is the Web Standard handler signature every runtime expects:
fetch: (request: Request, Env?, executionCtx?) => Response | Promise<Response>
= (request, ...rest) => {
return this.#dispatch(request, rest[1], rest[0], request.method)
}
The test-friendly path is request(), which accepts a string path or URL, wraps it in a Request, and delegates to fetch(). Both funnel into the same private #dispatch() method.
flowchart TD
F["app.fetch(request, env, ctx)"] --> D["#dispatch(request, ctx, env, method)"]
R["app.request('/path')"] --> |"wraps in new Request()"| F2["app.fetch(request)"]
F2 --> D
FE["addEventListener('fetch')"] --> D
Notice that getPath() is configurable via the constructor options. The default implementation at src/utils/url.ts#L106-L134 extracts the pathname by character-scanning the URL string rather than constructing a URL object — a deliberate performance optimisation that avoids a costly constructor on every request.
The #dispatch() Method
The heart of Hono lives in #dispatch(). Let's walk through it step by step:
Step 1: HEAD method handling (lines 407–410). Rather than requiring routers to understand HEAD, Hono recursively dispatches HEAD requests as GET and wraps the result in a bodyless Response:
if (method === 'HEAD') {
return (async () =>
new Response(null, await this.#dispatch(request, executionCtx, env, 'GET')))()
}
This is clever — it means every route handler automatically supports HEAD without knowing it.
Step 2: Path extraction and route matching (lines 412–413):
const path = this.getPath(request, { env })
const matchResult = this.router.match(method, path)
Step 3: Context creation (lines 415–421). A new Context is created for every request, receiving the raw request, the match result, environment bindings, and execution context.
Step 4: The fork — single-handler fast-path vs. compose (lines 423–459). This is where things get interesting, and we'll examine each path in detail below.
flowchart TD
A["#dispatch()"] --> B{"method === HEAD?"}
B -->|Yes| C["Re-dispatch as GET<br/>wrap Response(null)"]
B -->|No| D["getPath() + router.match()"]
D --> E["new Context(...)"]
E --> F{"matchResult has<br/>exactly 1 handler?"}
F -->|Yes| G["Fast path:<br/>call handler directly"]
F -->|No| H["compose() middleware chain"]
G --> I["Return Response"]
H --> I
compose(): The Middleware Onion
The compose() function is a 73-line implementation inspired by koa-compose. It takes an array of middleware/handler tuples and returns a function that executes them as a nested chain.
The core mechanism is a recursive dispatch(i) closure:
async function dispatch(i: number): Promise<Context> {
if (i <= index) {
throw new Error('next() called multiple times')
}
index = i
let handler
if (middleware[i]) {
handler = middleware[i][0][0]
} else {
handler = (i === middleware.length && next) || undefined
}
if (handler) {
res = await handler(context, () => dispatch(i + 1))
} else {
if (context.finalized === false && onNotFound) {
res = await onNotFound(context)
}
}
if (res && (context.finalized === false || isError)) {
context.res = res
}
return context
}
sequenceDiagram
participant D as dispatch(0)
participant MW1 as Middleware A
participant MW2 as Middleware B
participant H as Handler
participant NF as notFoundHandler
D->>MW1: handler(c, next)
MW1->>D: calls next()
D->>MW2: handler(c, next)
MW2->>D: calls next()
D->>H: handler(c, next)
H-->>D: returns Response
Note over MW2: after-phase runs
MW2-->>D: returns
Note over MW1: after-phase runs
MW1-->>D: returns
Three design decisions stand out:
- Double-call guard (line 33–34):
if (i <= index) throw. This prevents the subtle bugs that occur when middleware callsnext()twice. - Error delegation: If a handler throws an
Errorand anonErrorhandler exists, the error is caught and the error handler's response replaces the current one. - Not-found triggering: When all handlers are exhausted without finalizing the context,
onNotFoundis called automatically.
Tip: If you see the error "Context is not finalized. Did you forget to return a Response object or
await next()?" it means your handler callednext()without awaiting it, or returned nothing. Alwaysawait next()in middleware or return a Response in handlers.
The Single-Handler Fast-Path Optimisation
At hono-base.ts#L423-L442, Hono checks if only one handler matched. When true, it skips compose() entirely and invokes the handler directly:
if (matchResult[0].length === 1) {
let res
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) => resolved || (c.finalized ? c.res : this.#notFoundHandler(c)))
.catch((err) => this.#handleError(err, c))
: (res ?? this.#notFoundHandler(c))
}
This matters more than you might think. When a route has no middleware attached, the match result contains exactly one entry: the route handler itself. In this case, Hono avoids creating the dispatch() closure chain, the index tracking, and the async function wrapper. For a "hello world" benchmark, this is measurable.
The fast-path also distinguishes synchronous vs. asynchronous handlers: if the handler returns a plain Response (not a Promise), the entire dispatch is synchronous — no microtask queue delays.
Context Construction and Lazy HonoRequest
The Context class is created once per request. Its constructor is intentionally minimal — it stores the raw request and options, but defers almost everything:
constructor(req: Request, options?: ContextOptions<E>) {
this.#rawRequest = req
if (options) {
this.#executionCtx = options.executionCtx
this.env = options.env
this.#notFoundHandler = options.notFoundHandler
this.#path = options.path
this.#matchResult = options.matchResult
}
}
The HonoRequest wrapper is created lazily via a getter at line 366–369:
get req(): HonoRequest<P, I['out']> {
this.#req ??= new HonoRequest(this.#rawRequest, this.#path, this.#matchResult)
return this.#req
}
If your handler never accesses c.req, the HonoRequest is never constructed. This laziness extends to the response as well — c.res defaults to a null-body Response created on demand.
The c.text() fast-path is another clever optimisation. When no headers have been prepared, no status set, and the context isn't finalized, it returns new Response(text) directly — bypassing all header merging logic:
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))
}
The res setter at lines 414–434 handles header merging with special care for set-cookie headers, which must be accumulated rather than overwritten — a common source of bugs in frameworks that don't handle this correctly.
HonoRequest: Body Caching and Validated Data
HonoRequest wraps the standard Request with two critical additions: body caching and validated data storage.
The #cachedBody() method at lines 220–239 solves a fundamental Web API limitation: a ReadableStream body can only be consumed once. If your validator calls req.json() and then your handler also calls req.json(), the second call would fail. Hono's solution:
#cachedBody = (key: keyof Body) => {
const { bodyCache, raw } = this
const cachedBody = bodyCache[key]
if (cachedBody) return cachedBody
const anyCachedKey = Object.keys(bodyCache)[0]
if (anyCachedKey) {
return (bodyCache[anyCachedKey] as Promise<BodyInit>).then((body) => {
if (anyCachedKey === 'json') body = JSON.stringify(body)
return new Response(body)[key]()
})
}
return (bodyCache[key] = raw[key]())
}
The clever part: if you called .json() first and then need .text(), it serializes the cached JSON back to a string and wraps it in a temporary Response to call .text() on it. Cross-format conversion with no repeated network reads.
Validated data is stored per-target via addValidatedData() and retrieved with req.valid('json'). The validator middleware writes parsed data here; your handler reads it. No re-parsing.
HTTPException and Error Handling
HTTPException extends Error but carries an optional Response:
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 })
}
}
The default error handler in hono-base.ts#L35-L42 checks for getResponse on the error object (duck-typing rather than instanceof), which means you can throw HTTPException from any middleware and it will be automatically converted to the appropriate HTTP response.
flowchart TD
A["Handler throws error"] --> B{"error instanceof Error?"}
B -->|No| C["Re-throw (non-Error values pass through)"]
B -->|Yes| D{"onError handler registered?"}
D -->|Yes| E["Call onError(err, c)"]
D -->|No| F{"'getResponse' in err?"}
F -->|Yes| G["Return err.getResponse()"]
F -->|No| H["Return 500 Internal Server Error"]
Tip: Always throw
new HTTPException(status, { message })instead of returning error responses manually. This ensures your error handler middleware can intercept and transform errors consistently, and compose() handles the error flow correctly.
What's Next
We've traced the complete lifecycle from fetch() through dispatch, composition, and back to a Response. In the next article, we'll zoom into the five routers that Hono ships — each implementing the same interface but with radically different performance characteristics — and understand why SmartRouter's self-replacing match method makes the router selection entirely invisible at runtime.