Hono's middleware engine is a single exported function, compose, in src/compose.ts -- 73 lines including comments, modeled on koa-compose. Understanding it explains every middleware behavior in the framework: ordering, await next(), error capture, and the not-found fallback.
Registration: middleware is just a route
app.use() has no separate storage. In src/hono-base.ts it registers the handler on the router under the pseudo-method ALL, defaulting the path to *:
this.use = (arg1: string | MiddlewareHandler<any>, ...handlers: MiddlewareHandler<any>[]) => {
if (typeof arg1 === 'string') {
this.#path = arg1
} else {
this.#path = '*'
handlers.unshift(arg1)
}
handlers.forEach((handler) => {
this.#addRoute(METHOD_NAME_ALL, this.#path, handler)
})
return this as any
}
Because middleware and handlers live in the same route table, one router match() returns the full ordered chain for a request -- there is no second lookup for middleware.
Execution: recursive dispatch
compose turns that chain into a function of the Context. The core is a recursive dispatch(i):
async function dispatch(i: number): Promise<Context> {
if (i <= index) {
throw new Error('next() called multiple times')
}
index = i
let res
let isError = false
let handler
if (middleware[i]) {
handler = middleware[i][0][0]
context.req.routeIndex = i
} else {
handler = (i === middleware.length && next) || undefined
}
if (handler) {
try {
res = await handler(context, () => dispatch(i + 1))
} catch (err) {
if (err instanceof Error && onError) {
context.error = err
res = await onError(err, context)
isError = true
} else {
throw err
}
}
} else {
if (context.finalized === false && onNotFound) {
res = await onNotFound(context)
}
}
if (res && (context.finalized === false || isError)) {
context.res = res
}
return context
}
Reading it top to bottom gives you the framework's middleware guarantees:
await next()isdispatch(i + 1). Code beforeawait next()runs on the way in, code after runs on the way out -- the onion model falls out of ordinary async call nesting, not an event system.- Calling
next()twice throws (next() called multiple times), enforced by the monotonicindexcheck. context.req.routeIndex = iis howc.req.param()andc.req.routePathknow which matched route's parameter map applies at each layer of the chain.- One try/catch handles all errors. Any
Errorthrown by any downstream layer is caught once, stored oncontext.error(so outer middleware can inspect it afterawait next()), and converted by the app'sonErrorhandler. - 404 is the end of the chain. If dispatch runs past the last handler and nothing finalized the context,
onNotFoundproduces the response. A "not found" is not an error -- it is simply an un-finalized context. - A returned Response wins only if the context is not already finalized (or if this layer is handling an error). This is why middleware can either
return c.text(...)directly or setc.res-- both converge on the same finalization flag.
What a middleware looks like
Built-in middleware are factories returning a MiddlewareHandler. The logger shows the canonical shape -- work, await next(), then more work with the response available:
export const logger = (fn: PrintFunc = console.log): MiddlewareHandler => {
return async function logger(c, next) {
const { method, url } = c.req
// …
await log(fn, LogPrefix.Incoming, method, path)
const start = Date.now()
await next()
await log(fn, LogPrefix.Outgoing, method, path, c.res.status, time(start))
}
}
All 26 built-in middleware under src/middleware/ follow this pattern and are exported as their own subpaths (hono/logger, hono/etag, ...) -- see the exports reference.
The fast path caveat
compose only runs when more than one handler matched. A route with a single handler and no matching middleware takes a direct-call fast path in #dispatch (see How a request flows), so adding your first app.use('*') is the moment the compose machinery starts executing at all.