# A deep dive into Hono

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

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-08-11 | Topics: [JavaScript](https://flaviocopes.com/tags/js/) | Canonical: https://flaviocopes.com/how-hono-is-built/

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

[Hono](https://hono.dev/) is one of those frameworks.

You give it a route:

```js
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.

```text
GET /hello/Ada

Hello Ada
```

It looks almost too easy.

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

```js
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:

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

That last detail matters.

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:

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

`GET /hello/Ada` never reaches `handler`. The wildcard returns a response first.

The fix is to put the specific route first:

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

Registration order is not just code organization. It becomes application behavior.

## The request enters through `fetch()`

Now our request arrives:

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

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

```js
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 does not invent another HTTP world. It builds on the one 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 is useful. This router does not match routes itself forever. It chooses another router for the application.

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:

```text
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:

```text
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:

```text
/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:

```js
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.

It creates a `Context`.

The context is the `c` we see in every Hono handler:

```js
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:

```js
c.req.raw
```

Hono wraps it with `HonoRequest` to add convenient helpers:

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

These helpers do not replace the platform object. They sit around it.

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:

```js
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:

```js
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:

```text
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:

```js
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()`.

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:

```js
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:

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

Hono has helpers for the common cases:

```js
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:

```text
GET /hello/Ada
```

We end with something every supported runtime understands:

```js
new Response('Hello Ada')
```

## The adapter has one job

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

```js
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:

```text
Node request
  ↓
Web Request
  ↓
Hono application
  ↓
Web Response
  ↓
Node response
```

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

This is why the adapters can stay relatively small. They translate the runtime. They do not reimplement Hono.

If you are new to the runtime side of this, my [free Node.js course](https://flaviocopes.com/courses/nodejs/) 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.

```js
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:

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

const response = await app.request(request)
```

No local server is required. We are testing the Web contract directly.

## 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.

```js
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:

```js
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.

But the center is already here:

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

The architecture is small because the platform does the HTTP work.

## How I would use Hono

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

For example, a webhook receiver might need three things:

- 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.

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

```js
console.log(app.routes)
```

Confirm the method, path, and registration order.

Then test the exact request:

```js
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:

```text
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

Our request did not travel through a giant machine.

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

Hono created a context, composed the matching middleware, and called the handler. `c.text()` created a Web `Response`. The runtime returned it to the client.

That is the architecture:

```text
app.get()
  ↓
route table
  ↓
app.fetch(request)
  ↓
router match
  ↓
context and middleware
  ↓
handler
  ↓
Web Response
```

The clever parts make this path fast and pleasant to use.

The best part is that Hono keeps the path recognizable. Underneath the helpers, we still have a request coming in and a response going out.
