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 *:

src/hono-base.ts
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):

src/compose.ts
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() is dispatch(i + 1). Code before await 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 monotonic index check.
  • context.req.routeIndex = i is how c.req.param() and c.req.routePath know which matched route's parameter map applies at each layer of the chain.
  • One try/catch handles all errors. Any Error thrown by any downstream layer is caught once, stored on context.error (so outer middleware can inspect it after await next()), and converted by the app's onError handler.
  • 404 is the end of the chain. If dispatch runs past the last handler and nothing finalized the context, onNotFound produces 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 set c.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:

src/middleware/logger/index.ts
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.

Sources: src/compose.ts, src/hono-base.ts · last synced 2026-08-10 · 26de731 · version 4.13.1