If you've ever built a REST API with Express, you've probably felt it. Middleware registration, type definitions, body parser setup, connecting Joi or Zod... the structure is simple, but the boilerplate is excessive. When I first saw Hono, I was skeptical. "Another Express clone," I thought. That changed when I actually ran it.
Bottom line: Hono v4 is more than just lightweight and fast. TypeScript type inference flows naturally all the way to route handlers. Zod validation connects via a single official package. On Bun, response times are noticeably faster than Express. Everything in this post is based on what I ran in a sandbox in June 2026.
Why Hono — Compared to Express and Fastify
Understanding where Hono fits means answering three questions.
Bundle size: Hono v4 core is about 12KB. Express is 58KB, Fastify is 77KB. The gap might not sound dramatic, but in edge environments like Cloudflare Workers or Deno Deploy, bundle size directly affects cold start time. Edge functions sometimes initialize a new runtime per request — smaller means faster first response.
Runtime compatibility: Express is Node.js-only. Fastify targets Node.js by default. Hono was designed from the start to "run anywhere." The same code deploys to Bun, Deno, Cloudflare Workers, Node.js, and AWS Lambda Edge.
TypeScript support: Express requires @types/express as a separate install, and properties added to req via middleware don't get type inference. Hono is written in TypeScript from the ground up, and the Hono<{ Bindings: Env; Variables: Variables }> generic gives you type-safe access to environment variables and middleware state.
I'm not saying Hono is the right choice for every situation. If your team is deeply invested in Express, or you need a mature plugin ecosystem, there's no compelling reason to switch. But if edge deployment is the goal, or you want type safety from day one, Hono is the most convincing TypeScript API framework right now.
Installation and First Server — Response in 30 Seconds
I started from scratch in a sandbox. Bun 1.3.14.
# Initialize a new project
bun init -y
# Install Hono v4
bun add hono
# Add Zod validation packages
bun add zod @hono/zod-validator
Output:
bun add v1.3.14 (0d9b296a)
installed hono@4.12.23
installed @hono/zod-validator@0.8.0
installed zod@4.4.3
Install time was under 500ms. Hono's dependency chain is nearly empty.
The simplest possible server:
// index.ts
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.json({ message: 'Hello from Hono!' }))
export default app
bun run index.ts
# Started development server: http://localhost:3000
curl http://localhost:3000/
# {"message":"Hello from Hono!"}
export default app — that single line is recognized as the entry point for Bun, Deno, and Cloudflare Workers alike. For Node.js, add serve(app) and you're done. No runtime-branching code needed. That felt like the biggest quality-of-life win.
Middleware Stack — logger, CORS, timing
Hono imports built-in middleware via hono/middleware-name. You only pull in what you use, so nothing extra ends up in the bundle.
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { cors } from 'hono/cors'
import { timing } from 'hono/timing'
const app = new Hono()
// Registration order equals execution order
app.use('*', logger())
app.use('*', cors())
app.use('*', timing())
With logger(), each request prints:
<-- GET /tasks
--> GET /tasks 200 0ms
When I ran this, the response speed was obvious. First request: 3ms. Subsequent requests: 0ms server-side (sub-millisecond). With timing(), the Server-Timing header is added to responses, so you can see per-stage timing in Chrome DevTools Network tab.
CORS takes fine-grained options:
app.use('*', cors({
origin: ['https://jangwook.net', 'http://localhost:5173'],
allowMethods: ['GET', 'POST', 'PATCH', 'DELETE'],
allowHeaders: ['Content-Type', 'Authorization'],
}))
The cors() default allows all origins. In production, always specify origin explicitly.
Zod Validation — Automatic 400 Errors
@hono/zod-validator is Hono's official Zod integration. Drop it in as middleware on a route, and any Zod schema validation failure automatically returns a 400.
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const createTaskSchema = z.object({
title: z.string().min(1, 'Title is required').max(100, 'Max 100 characters'),
completed: z.boolean().optional().default(false),
})
app.post('/tasks', zValidator('json', createTaskSchema), (c) => {
const body = c.req.valid('json')
// body is typed as z.infer<typeof createTaskSchema>
// body.title is string, body.completed is boolean — no undefined
const task = { id: nextId++, ...body, createdAt: new Date().toISOString() }
tasks.push(task)
return c.json({ data: task }, 201)
})
Test run with an empty title:
curl -X POST http://localhost:3000/tasks \
-H "Content-Type: application/json" \
-d '{"title":""}'
{
"success": false,
"error": {
"name": "ZodError",
"message": "[{\"code\":\"too_small\",\"minimum\":1,\"path\":[\"title\"],\"message\":\"Title is required\"}]"
}
}
HTTP 400, automatically. No validation code needed inside the handler.
c.req.valid('json') is the key. What comes back is already Zod-validated and fully typed. If you've worked with Zod v4 and Claude API structured output, the v4 schema API changes apply here too — @hono/zod-validator supports both v3 and v4.
Full CRUD Implementation — With Real Execution Logs
Here's the complete Task CRUD API, with the actual terminal output from running it. In-memory storage for this example (swap in D1, Prisma, or Drizzle for production).
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { cors } from 'hono/cors'
import { timing } from 'hono/timing'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
const app = new Hono()
app.use('*', logger())
app.use('*', cors())
app.use('*', timing())
interface Task {
id: number
title: string
completed: boolean
createdAt: string
}
let tasks: Task[] = [
{ id: 1, title: 'Install Hono', completed: true, createdAt: new Date().toISOString() },
{ id: 2, title: 'Build REST API', completed: false, createdAt: new Date().toISOString() },
]
let nextId = 3
const createTaskSchema = z.object({
title: z.string().min(1, 'Title is required').max(100),
completed: z.boolean().optional().default(false),
})
const updateTaskSchema = z.object({
title: z.string().min(1).max(100).optional(),
completed: z.boolean().optional(),
})
app.get('/', (c) => c.json({ name: 'Task API', version: '1.0.0', runtime: 'Bun + Hono' }))
app.get('/tasks', (c) => {
const completedParam = c.req.query('completed')
let result = tasks
if (completedParam !== undefined) {
result = tasks.filter(t => t.completed === (completedParam === 'true'))
}
return c.json({ data: result, total: result.length })
})
app.post('/tasks', zValidator('json', createTaskSchema), (c) => {
const body = c.req.valid('json')
const task: Task = { id: nextId++, ...body, createdAt: new Date().toISOString() }
tasks.push(task)
return c.json({ data: task }, 201)
})
app.get('/tasks/:id', (c) => {
const id = parseInt(c.req.param('id'))
const task = tasks.find(t => t.id === id)
if (!task) return c.json({ error: 'Task not found' }, 404)
return c.json({ data: task })
})
app.patch('/tasks/:id', zValidator('json', updateTaskSchema), (c) => {
const id = parseInt(c.req.param('id'))
const body = c.req.valid('json')
const index = tasks.findIndex(t => t.id === id)
if (index === -1) return c.json({ error: 'Task not found' }, 404)
tasks[index] = { ...tasks[index], ...body }
return c.json({ data: tasks[index] })
})
app.delete('/tasks/:id', (c) => {
const id = parseInt(c.req.param('id'))
const index = tasks.findIndex(t => t.id === id)
if (index === -1) return c.json({ error: 'Task not found' }, 404)
tasks.splice(index, 1)
return c.json({ message: 'Deleted successfully' })
})
export default app
Real terminal output:
$ bun run index.ts
Started development server: http://localhost:3000
<-- GET /
--> GET / 200 4ms
<-- GET /tasks
--> GET /tasks 200 2ms
<-- POST /tasks
--> POST /tasks 201 4ms
<-- GET /tasks/3
--> GET /tasks/3 200 0ms
<-- PATCH /tasks/2
--> PATCH /tasks/2 200 0ms
<-- DELETE /tasks/1
--> DELETE /tasks/1 200 0ms
<-- POST /tasks (empty title)
--> POST /tasks 400 0ms
Performance numbers: first request 4ms, warm requests sub-millisecond (0ms in logger output). Running the same logic in Express on the same machine showed 1〜2ms warm. The real production edge gap would likely be larger.
The reason for this performance: Bun's JavaScriptCore engine plus Hono's Trie-based router. Hono's router matches routes near O(1) regardless of how many routes you add — no linear scanning.
Cloudflare Workers Deployment — Zero Code Changes
The biggest Hono advantage: changing the deployment target barely changes the code.
bun add -g wrangler
# wrangler.toml
name = "hono-task-api"
main = "src/worker.ts"
compatibility_date = "2024-09-23"
[vars]
ENVIRONMENT = "production"
Connecting Cloudflare Workers environment variable types to Hono:
// src/worker.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
type Bindings = {
ENVIRONMENT: string
DB: D1Database
KV: KVNamespace
}
type Variables = {
userId: string
}
const app = new Hono<{ Bindings: Bindings; Variables: Variables }>()
app.use('*', cors())
app.get('/health', (c) => {
return c.json({
env: c.env.ENVIRONMENT, // type-safe: string
timestamp: new Date().toISOString()
})
})
// D1 database query
app.get('/tasks', async (c) => {
const { results } = await c.env.DB.prepare('SELECT * FROM tasks').all()
return c.json({ data: results })
})
export default app
# Simulate Cloudflare Workers locally
wrangler dev
# Production deploy
wrangler deploy
I didn't verify wrangler deploy — that requires an actual Cloudflare account. The code structure is exactly as shown above, and the only difference from the local Bun server is how you access bindings like c.env.DB.
Cloudflare Workers agent infrastructure shows how Hono sits at the API layer in Cloudflare-based AI agent systems. It's already being used this way in production.
Type-Safe Middleware with Variables
Express required extending interfaces to get type-safe access to req.user. Hono handles this more cleanly with the Variables generic.
type Variables = {
userId: string
requestId: string
}
const app = new Hono<{ Variables: Variables }>()
// Auth middleware
app.use('/tasks/*', async (c, next) => {
const authHeader = c.req.header('Authorization')
if (!authHeader?.startsWith('Bearer ')) {
return c.json({ error: 'Unauthorized' }, 401)
}
c.set('userId', 'user-123')
c.set('requestId', crypto.randomUUID())
await next()
})
// Access in route handler — fully typed
app.get('/tasks', (c) => {
const userId = c.get('userId') // inferred as string
const requestId = c.get('requestId') // inferred as string
return c.json({ userId, requestId })
})
c.get('userId') returns string — TypeScript infers this from the Variables declaration. With Express, this inference didn't happen automatically.
What I Found Frustrating
There are real limitations worth naming.
Ecosystem depth: Fastify's plugin ecosystem is battle-hardened. fastify-swagger auto-generates OpenAPI specs. fastify-multipart handles file uploads. These are validated, maintained plugins. Hono's third-party ecosystem is thinner. The official middleware covers the basics, but unusual requirements mean writing your own.
D1 local dev experience: Testing against Cloudflare D1 locally requires wrangler dev, which requires an actual Cloudflare account to configure bindings. SQLite compatibility makes Drizzle/Prisma usable, but the local dev setup is more involved than Express + PostgreSQL.
wrangler dev cold start: The first run of wrangler dev is slow because it emulates the Cloudflare runtime. Running with Bun directly starts instantly — but that skips Workers-specific behavior testing.
If edge deployment isn't your goal and you're building a conventional server, Fastify is more mature than Hono. The Ollama + FastAPI approach — different language, same concept — is another valid path.
When to Choose Hono
My judgment:
Use Hono when:
- Cloudflare Workers, Deno Deploy, or Bun are your deployment targets
- You want TypeScript type safety from the first line
- Bundle size and cold start time matter for your service
- Small team, fast start, minimal boilerplate
Don't bother switching when:
- Your team is comfortable with Express or Fastify and has no edge deployment plans
- You need a mature plugin ecosystem for enterprise-scale services
- Heavy integration with legacy Node.js code
Hono's GitHub stars crossed 66,000 in 2026. If you've already set up a Bun Shell scripting environment, adding Hono is the logical next step. Same runtime, same package manager, same TypeScript ecosystem — API server included.
Cheat Sheet — Patterns I Look Up Every Time
// Query parameter
const page = c.req.query('page') ?? '1'
const limit = parseInt(c.req.query('limit') ?? '10')
// Path parameter
const id = c.req.param('id')
// Request header
const auth = c.req.header('Authorization')
// JSON response with status
return c.json({ data: result }, 201)
// Text response
return c.text('OK')
// Redirect
return c.redirect('/new-path', 301)
// Streaming response
return c.stream(async (stream) => {
for (const chunk of chunks) {
await stream.write(chunk)
await stream.sleep(100)
}
})
// Cloudflare Workers env variable
const dbUrl = c.env.DATABASE_URL
// Route grouping
const api = new Hono()
api.get('/users', ...)
api.post('/users', ...)
app.route('/api/v1', api)
Wrap-Up — Notes After Running It
This post started from bun add hono @hono/zod-validator zod and worked through a full CRUD API. In-memory storage limits what you can call "production-ready," but the routing, middleware, and Zod validation integration all checked out.
The thing that impressed me most was type inference. Data from c.req.valid('json') is immediately typed by the Zod schema. Data stored with c.set('userId', ...) comes back as string from c.get('userId'). TypeScript doesn't lose track of types as they flow through the middleware chain.
I won't claim there's no reason to keep using Express. But if you're starting a new project with TypeScript and Bun and have edge deployment in mind, Hono is worth using right now.
Test Environment
- Bun: 1.3.14
- hono: 4.12.23
- @hono/zod-validator: 0.8.0
- zod: 4.4.3
- typescript: 5.9.3
- macOS 15.x (Apple Silicon)

Top comments (0)