Key Takeaway
Hono is a zero-dependency web framework built on Web Standards. You write one API using the Request and Response objects the platform already provides, then run it unchanged on Cloudflare Workers, Bun, Node, and Deno by swapping a thin adapter. Its typed RPC client gives the frontend full type safety with no codegen.
I have shipped enough Express services to know the tax it quietly charges: it is glued to Node's http module, so the day you want to run the same handlers on an edge runtime, you rewrite. Hono takes the opposite bet. It is built on the Web Standard Request and Response objects — the same primitives you already use in the browser and in a Service Worker — so the framework itself carries no runtime assumptions. As of July 2026 it sits at v4.12, has zero dependencies, and the hono/tiny preset weighs under 12kB.
The practical payoff is portability. I can prototype an API on Bun locally for the fast startup, deploy it to Cloudflare Workers for global low-latency reads, and keep a Node build around for a legacy VPS — all from the same source tree. This post walks through routing, middleware, the runtime adapters, and the typed RPC client, with the trade-offs I actually hit.
Routing: fast because the matcher is precompiled
Hono's default RegExpRouter compiles every route you register into a single large regular expression. Matching an incoming path is therefore close to constant time regardless of how many routes exist, rather than walking a radix tree per request. In published benchmarks this makes route lookup meaningfully faster than the find-my-way tree that powers Fastify. The routing API itself will feel familiar — method handlers, path params, and wildcards — but the shape of the objects you touch is pure Web Standard.
import { Hono } from 'hono'
const app = new Hono()
app.get('/health', (c) => c.json({ ok: true }))
app.get('/users/:id', (c) => {
const id = c.req.param('id') // typed path param
const verbose = c.req.query('verbose') // query string
return c.json({ id, verbose })
})
app.post('/users', async (c) => {
const body = await c.req.json() // Web Standard Request under the hood
return c.json({ created: body }, 201)
})
export default appMiddleware: one signature, composes cleanly
Middleware in Hono is a function that receives the context and a next callback. You do work before calling next, await it, then do work after — the classic onion model, but every piece is portable because none of it reaches for Node globals. The official packages cover the boring-but-essential list: CORS, JWT verification, bearer auth, request logging, compression, ETag, and rate limiting. You mount them per path or globally.
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { cors } from 'hono/cors'
import { jwt } from 'hono/jwt'
const app = new Hono()
app.use('*', logger())
app.use('/api/*', cors({ origin: 'https://www.matthewswong.com' }))
app.use('/api/*', jwt({ secret: process.env.JWT_SECRET! }))
// custom middleware — same signature
app.use('*', async (c, next) => {
const start = Date.now()
await next()
c.header('X-Response-Time', `${Date.now() - start}ms`)
})Order matters and short-circuiting is explicit: if a middleware returns a Response instead of calling next, the chain stops there. That is how auth guards reject a request cleanly — return a 401 and never touch the handler.
The same app on Workers, Bun, and Node
This is the part that sold me. Your Hono app is just an object with a fetch method that takes a Request and returns a Response. Each runtime has its own way of feeding requests in, so Hono ships a thin adapter per platform — the app code never changes, only the entry file does. Cloudflare Workers and Bun both speak fetch natively, so you export the app directly. Node needs a bridge from its http module, which the Node adapter provides.
// app.ts — shared by ALL runtimes, never changes
import { Hono } from 'hono'
export const app = new Hono().get('/', (c) => c.text('Hello from anywhere'))
// --- Cloudflare Workers: worker.ts
import { app } from './app'
export default app // Workers calls app.fetch
// --- Bun: bun.ts -> bun run bun.ts
import { app } from './app'
export default { fetch: app.fetch, port: 3000 }
// --- Node: node.ts -> node node.ts
import { serve } from '@hono/node-server'
import { app } from './app'
serve({ fetch: app.fetch, port: 3000 })Portability ends where platform APIs begin. If a handler reaches for a Cloudflare KV binding, a Bun-specific file API, or a Node stream, that route stops being portable. Keep runtime-specific access behind a small interface you can swap, or you will lose the very property you chose Hono for.
Typed RPC: type safety with no codegen
Hono's RPC feature shares your route types directly with the frontend. There is no OpenAPI file and no code generation step — you export one TypeScript type from the server and the hc client reads it. The catch, and it is the single most common footgun, is that inference only survives if you chain your handlers into one expression. Break the chain into separate statements and the client loses its types. I use a request validator so the inferred input and output are actually enforced at runtime, not just in the editor.
// server.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const routes = new Hono()
.post(
'/posts',
zValidator('json', z.object({ title: z.string() })),
(c) => {
const { title } = c.req.valid('json')
return c.json({ id: 1, title }, 201)
},
) // <-- keep the chain unbroken so types are inferred
export type AppType = typeof routes
export default routes
// client.ts — frontend, fully typed, no codegen
import { hc } from 'hono/client'
import type { AppType } from './server'
const client = hc<AppType>('https://api.example.com')
const res = await client.posts.$post({ json: { title: 'Hello' } })
const data = await res.json() // data.title is typed as stringFor a large API, compile the client type ahead of time instead of letting your editor recompute it on every keystroke. The docs recommend wrapping hc in a helper whose return type is fixed with ReturnType, so tsc does the heavy type instantiation once at build time and your IDE stays fast.
Where Hono fits — and where it does not
I reach for Hono when the workload is API-shaped and I care about deploying to more than one place: edge functions, a Bun service, a small Node container. It is deliberately unopinionated about the layers above HTTP, so if you want dependency injection, decorators, and a rigid module system, a batteries-included framework like NestJS will feel more complete. The table below is how I decide.
| Concern | Hono | Express |
|---|---|---|
| Built on | Web Standards (fetch) | Node http module |
| Runtimes | Workers, Bun, Node, Deno | Node only |
| Dependencies | Zero | Several transitive |
| Typed client | Built-in RPC, no codegen | None |
| Router | Precompiled RegExpRouter | Linear middleware match |
None of this makes Express wrong — it makes Hono a better default for the edge era. If your API might outlive its first runtime, writing it against Web Standards today means you are not rewriting it the day you move. That single property has saved me more time than any micro-benchmark ever will.

