A deep dive into Hono

By

Follow one request through Hono to see how routing, middleware, context, Web Standards, and runtime adapters work together.

~~~

I like frameworks that disappear when I look closely at them.

Hono is one of those frameworks.

You give it a route:

import { Hono } from 'hono'

const app = new Hono()

app.get('/hello/:name', (c) => {
  return c.text(`Hello ${c.req.param('name')}`)
})

export default app

Then a request arrives and a response comes back.

GET /hello/Ada

Hello Ada

What happens between those two moments?

Let’s follow this request through Hono. We will start when the application registers the route and stop when the runtime receives a Web Response.

By the end, the small API will make much more sense.

Before the request arrives

Our story starts before anyone visits /hello/Ada.

The application runs this line:

app.get('/hello/:name', handler)

Hono does not start listening on a port here. It does not create a response or run the handler.

It records four pieces of information:

method:  GET
path:    /hello/:name
handler: handler
order:   where we registered it

Hono keeps matching handlers in registration order. Middleware and routes registered first get the first chance to handle the request.

For example, this fallback hides the route below it:

app.get('*', (c) => c.text('Not found', 404))
app.get('/hello/:name', handler)

The wildcard returns a response before GET /hello/Ada can reach handler.

Put the specific route first:

app.get('/hello/:name', handler)
app.get('*', (c) => c.text('Not found', 404))

The registration order now gives the specific handler its chance to respond.

The request enters through fetch()

Now our request arrives:

new Request('http://localhost/hello/Ada')

The center of every Hono application is app.fetch().

const response = await app.fetch(request)

That method receives a standard Web Request and returns a standard Web Response.

This is the idea that makes Hono portable.

Cloudflare Workers already use Request and Response. Bun and Deno understand them too. Node.js and AWS Lambda need an adapter at the edge, but the Hono application in the middle stays the same.

The framework builds on the HTTP model JavaScript runtimes already share.

Hono chooses a router

The request has entered the application. Hono now needs to find a matching route.

A normal new Hono() uses a SmartRouter.

The name describes what it does. It chooses another router for the application instead of matching routes itself forever.

By default, it tries RegExpRouter first. If the registered patterns do not fit, it falls back to TrieRouter.

This choice happens on the first request.

Once Hono finds a router that accepts the complete route table, it keeps that router. Later requests skip the selection work.

Conceptually, the first request does this:

load all routes
try RegExpRouter
fall back to TrieRouter if needed
keep the winner
match /hello/Ada

The next request starts at the last step.

I like this detail because it shows how Hono thinks about performance. Flexible setup work happens once. The repeated path stays short.

The router finds /hello/:name

Our request has a method and a pathname:

GET
/hello/Ada

The router compares them with the registered routes.

Static paths such as /health are easy. Hono can find them with a direct lookup.

Dynamic paths need more work:

/hello/:name
/posts/:slug
/users/:id/settings

RegExpRouter combines compatible dynamic routes into an optimized matcher. It does not walk through a long list and test each route separately on every request.

When /hello/Ada matches, the router returns two important things:

  • the handler for /hello/:name
  • the position of the name parameter

It does not need to create a large parameter object immediately. Hono can wait until our code asks for it.

That moment arrives here:

c.req.param('name')

Only then does Hono turn the captured part of the pathname into Ada.

This lazy work appears throughout Hono. Parse only what the application uses.

Hono creates the context

The route matches, but Hono cannot call the handler with the raw Request alone.

Hono creates the Context we see as c in every handler:

app.get('/hello/:name', (c) => {
  return c.text(`Hello ${c.req.param('name')}`)
})

It connects the request, response helpers, environment bindings, variables, status, and headers for this one trip through the application.

The original Web Request is still available:

c.req.raw

Hono wraps it with HonoRequest to add convenient helpers:

c.req.param('name')
c.req.query('page')
c.req.header('authorization')
await c.req.json()

These helpers wrap the platform object, which remains available underneath.

This distinction is important when a Web API already solves the problem. You can drop down to the original request instead of waiting for a framework-specific feature.

Middleware joins the journey

Let’s add one piece of middleware:

app.use('*', async (c, next) => {
  const startedAt = Date.now()

  await next()

  const duration = Date.now() - startedAt
  c.header('Server-Timing', `app;dur=${duration}`)
})

Now /hello/Ada matches two handlers:

  1. the timing middleware
  2. the route handler

Hono composes them in that order.

The middleware runs until await next(). Then Hono enters the route handler.

The route creates the response:

return c.text('Hello Ada')

Control then returns to the middleware. It calculates the duration and adds the Server-Timing header.

The flow looks like this:

timing middleware starts
  route handler runs
  route handler returns a response
timing middleware adds a header
response leaves the application

This is often called onion-style middleware. Each middleware can do work before and after the next handler.

It is easier to understand when we follow one request than when we describe a middleware stack in abstract terms.

A middleware can stop the trip

Now imagine an authentication middleware:

app.use('/admin/*', async (c, next) => {
  const token = c.req.header('authorization')

  if (!token) {
    return c.text('Unauthorized', 401)
  }

  await next()
})

If the token is missing, the middleware returns a response without calling next(), so the route handler never runs.

This is how authentication, caching, redirects, and other early responses work. A handler can continue the journey or finish it.

c.text() creates a real response

Our handler ends with this line:

return c.text('Hello Ada')

c.text() is convenient, but it is not magic. It creates a Web Response with a text body and the correct content type.

This is the longer version:

return new Response('Hello Ada', {
  headers: {
    'Content-Type': 'text/plain; charset=UTF-8',
  },
})

Hono has helpers for the common cases:

c.text('Hello Ada')
c.json({ message: 'Hello Ada' })
c.html('<h1>Hello Ada</h1>')
c.redirect('/login')

They all lead back to a standard Response.

The context also remembers metadata added during the request. If middleware calls c.header() or a handler calls c.status(), Hono applies those values when it finalizes the response.

By the time app.fetch() resolves, our framework journey is over.

We started with:

GET /hello/Ada

We end with something every supported runtime understands:

new Response('Hello Ada')

The adapter has one job

On Cloudflare Workers, the application can be the exported fetch handler:

export default app

The runtime already sends a Web Request and expects a Web Response.

Node.js speaks a different HTTP API. It gives us IncomingMessage and ServerResponse objects.

The Node adapter translates at the boundary:

Node request

Web Request

Hono application

Web Response

Node response

The router, middleware, handlers, and context do not care where the request came from.

The adapters can stay relatively small because they translate the runtime without reimplementing Hono.

If you are new to the runtime side of this, my free Node.js course explains the HTTP server and request lifecycle from the beginning.

Let’s test the complete journey

Hono exposes app.request() for testing.

It creates a request, sends it through the same fetch() path, and returns the response.

import { describe, expect, it } from 'vitest'
import app from './app.js'

describe('GET /hello/:name', () => {
  it('greets the person', async () => {
    const response = await app.request('/hello/Ada')

    expect(response.status).toBe(200)
    expect(await response.text()).toBe('Hello Ada')
  })
})

I prefer this kind of test to calling the handler directly.

It checks the route pattern, parameter extraction, middleware, context helpers, and response. Those are exactly the pieces that can disagree in a real application.

You can also pass a complete Request when you need headers or a body:

const request = new Request('http://localhost/hello/Ada', {
  headers: {
    Authorization: 'Bearer secret',
  },
})

const response = await app.request(request)

This tests the Web contract directly without starting a local server.

A tiny Hono-like application

We can now build a tiny version of the path we followed.

This is not a router I would use in production. It is just enough code to make the architecture concrete.

const routes = []

const get = (path, handler) => {
  routes.push({ method: 'GET', path, handler })
}

const fetch = async (request) => {
  const url = new URL(request.url)
  const route = routes.find((route) => {
    return route.method === request.method && route.path === url.pathname
  })

  if (!route) {
    return new Response('Not found', { status: 404 })
  }

  const context = {
    req: request,
    text: (body) => new Response(body),
  }

  return route.handler(context)
}

We can register and call a route:

get('/hello', (c) => c.text('Hello Ada'))

const request = new Request('http://localhost/hello')
const response = await fetch(request)

console.log(await response.text()) //Hello Ada

Real Hono adds fast route compilation, parameters, middleware composition, error handling, typed helpers, and adapters. The center is already here:

register handlers
receive a Request
find the handlers
create a context
return a Response

The platform does the HTTP work, which keeps the architecture small.

How I would use Hono

I would use Hono for a focused API on Cloudflare Workers.

For example, a webhook receiver might need to:

  • verify a signature
  • store an event
  • return a quick response

Hono gives me routing and middleware without hiding Cloudflare bindings or Web APIs. I can keep the application code close to the runtime while still having a clean structure.

I would also use it when I want the same contract in tests and production. The code receives a Request in both places and returns a Response in both places.

Using the same contract removes a lot of test setup.

Hono is also attractive for small services that may move between runtimes. The adapter changes, while the route and middleware code can stay largely the same.

I would not choose it because I expect to move runtimes every week. Portability is useful even when I never move. It keeps the framework attached to a stable standard instead of a proprietary request API.

Where I would not use it

I would not use Hono just to add a framework to a single static page.

I would also pause before moving a large Node.js application that depends on Node-specific middleware. A Web Request does not make every database driver, file API, background job, or third-party package portable.

Hono makes the HTTP boundary portable. It cannot make the complete application portable by itself.

For a large full-stack product, I would also decide how I want to handle rendering, data loading, forms, authentication, and deployment before choosing the router. Hono can be part of that system, but it does not need to own everything.

What to check when a route does not work

The journey gives us a useful debugging order.

First, inspect the route table:

console.log(app.routes)

Confirm the method, path, and registration order.

Then test the exact request:

const response = await app.request('/hello/Ada')

console.log(response.status)
console.log(await response.text())

If the route works through app.request() but fails after deployment, look at the adapter or runtime configuration. The Hono application already handled the Web request correctly.

If the route does not work in the test, follow the same path we used in this tutorial:

Was the route registered?
Does the method match?
Does the pathname match?
Did earlier middleware return a response?
Did every middleware that continues call await next()?
Did the final handler return a Response?

This is much faster than treating the framework as a black box.

The whole story

Hono registered a route. On the first request, SmartRouter chose a matcher. The matcher found /hello/:name and captured Ada.

It then created a context, composed the matching middleware, and called the handler. c.text() created a Web Response for the runtime to return to the client.

app.get()

route table

app.fetch(request)

router match

context and middleware

handler

Web Response

What I like best is that Hono keeps the path recognizable. The routing and middleware code make it fast, but underneath the helpers a request still comes in and a response goes out.

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about js: