Hono vs itty-router: Edge-First API Frameworks Compared
TL;DR
Hono is the better choice for most edge projects. itty-router wins on bundle size for ultra-minimal Workers. Hono (~1.8M weekly downloads) provides a full-featured framework with middleware, validation, and a growing ecosystem. itty-router (~200K) is a 500-byte router — nothing more. Choose itty-router when you need the absolute minimum footprint and don't need middleware. Choose Hono for everything else.
Key Takeaways
- Hono: ~1.8M weekly downloads — itty-router: ~200K (npm, March 2026)
- itty-router is ~500 bytes — Hono is ~12KB (still tiny by Node.js standards)
- Hono has TypeScript generics — itty-router has minimal types
- Hono has middleware ecosystem — itty-router has none built-in
- Both target Cloudflare Workers — but Hono also targets Node, Bun, Deno
Context: Edge Runtime Constraints
Cloudflare Workers has a 1MB compressed script size limit (free tier: 1MB total, paid: 10MB). This constraint doesn't matter for most Hono applications — 12KB of framework overhead leaves plenty of room. But it explains why itty-router exists and why the bundle size conversation matters at all for this comparison.
Workers also start cold much faster than traditional serverless functions. The startup cost of a framework is nearly irrelevant at this scale. The relevant metrics are: bundle size (if you're near the limit), developer experience, TypeScript support, and the availability of middleware for cross-cutting concerns like auth, CORS, and validation.
For most teams building real APIs on Cloudflare Workers, Deno Deploy, or other edge platforms, Hono's 12KB is not a meaningful constraint. Even if you add Zod for validation (~50KB minified) and a few other dependencies, you're well within the Workers script size limit. The question becomes: what do you get for those extra ~11.5KB over itty-router? The answer is essentially everything that makes building a real API less tedious: typed context, middleware, validation, and error handling that you'd otherwise write yourself.
Bundle Size vs Features Trade-off
itty-router achieves 500 bytes by doing exactly one thing: routing. There is no middleware system, no typed context, no error handling, no validation, no request body helpers. You get a function that matches a URL path to a handler and calls it with the request object. Everything else — CORS headers, auth checks, input validation, error responses, JSON serialization — is your responsibility to write for every route.
This is a deliberate design philosophy. itty-router's author, Kevin Fang, explicitly targets the use case of ultra-minimal Workers where you want the absolute minimum of code between the incoming request and your handler. The 500-byte claim is accurate: the core router has no runtime dependencies and the minified bundle is genuinely that small.
itty-router: ~500 bytes (minified)
hono/tiny: ~3KB
hono: ~12KB (with middleware hooks)
express: ~250KB+ with deps
fastify: ~350KB+ with deps
In the Cloudflare Workers context where you often compile the entire worker to a single JS bundle, this size comparison is genuinely relevant. A Worker that proxies one type of request, handles webhooks, or serves redirects might legitimately benefit from itty-router's near-zero footprint. Every library you add to a Worker is bundled into the final artifact.
That said, the practical threshold matters. At 12KB, Hono is still an order of magnitude smaller than Express or Fastify. The real question is whether your Worker is so minimal that even 12KB of framework is overhead you'd rather avoid — a narrow category in practice.
Hono's Middleware Ecosystem
The most concrete difference between Hono and itty-router is what you don't have to write yourself. Hono ships first-party middleware for every common cross-cutting concern:
// Hono — first-party middleware for common needs
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { bearerAuth } from 'hono/bearer-auth';
import { cache } from 'hono/cache';
import { compress } from 'hono/compress';
import { secureHeaders } from 'hono/secure-headers';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
const app = new Hono<{ Bindings: { DB: D1Database; API_TOKEN: string } }>();
// Global middleware
app.use('*', logger());
app.use('*', cors({ origin: 'https://yourapp.com' }));
app.use('*', secureHeaders());
// Route-scoped middleware
app.use('/api/*', bearerAuth({ token: (c) => c.env.API_TOKEN }));
// Validation middleware inline with route
app.post('/api/users',
zValidator('json', z.object({
name: z.string().min(1),
email: z.string().email(),
})),
async (c) => {
const body = c.req.valid('json'); // { name: string; email: string }
const stmt = c.env.DB.prepare('INSERT INTO users (name, email) VALUES (?, ?)');
await stmt.bind(body.name, body.email).run();
return c.json({ success: true }, 201);
}
);
export default app;
With itty-router, every one of those features is code you write yourself:
// itty-router — you implement everything manually
import { Router } from 'itty-router';
const router = Router();
// CORS — manual headers
function withCors(response) {
response.headers.set('Access-Control-Allow-Origin', 'https://yourapp.com');
return response;
}
// Auth — manual token check
async function withAuth(request, env) {
const token = request.headers.get('Authorization')?.slice(7);
if (token !== env.API_TOKEN) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
}
router.post('/api/users', withAuth, async (request, env) => {
const body = await request.json();
// Manual validation
if (!body.name || typeof body.name !== 'string') {
return new Response(JSON.stringify({ error: 'name required' }), { status: 400 });
}
if (!body.email || !body.email.includes('@')) {
return new Response(JSON.stringify({ error: 'valid email required' }), { status: 400 });
}
await env.DB.prepare('INSERT INTO users (name, email) VALUES (?, ?)')
.bind(body.name, body.email)
.run();
return withCors(
new Response(JSON.stringify({ success: true }), {
status: 201,
headers: { 'Content-Type': 'application/json' },
})
);
});
export default { fetch: router.handle };
The itty-router version requires significantly more boilerplate. For a single route this is manageable. For an API with 10+ routes, all requiring auth and CORS, the manual overhead compounds quickly. More importantly, each manual implementation is a surface for bugs — Hono's bearerAuth and cors middleware are tested and battle-hardened.
TypeScript Support
Hono's TypeScript generics are one of its strongest features. The Hono class accepts type parameters for Bindings (environment variables and KV/D1 bindings), Variables (middleware-set values), and route parameters are inferred from path strings.
// Hono TypeScript — full generics through the request lifecycle
import { Hono } from 'hono';
type Env = {
Bindings: {
DB: D1Database;
KV: KVNamespace;
API_SECRET: string;
};
Variables: {
userId: string; // Set by auth middleware
};
};
const app = new Hono<Env>();
// Auth middleware sets userId in Variables
app.use('/api/*', async (c, next) => {
const token = c.req.header('Authorization')?.slice(7);
const userId = verifyToken(token, c.env.API_SECRET);
if (!userId) return c.json({ error: 'Unauthorized' }, 401);
c.set('userId', userId); // Typed — must match Variables
await next();
});
app.get('/api/users/:id', async (c) => {
const id = c.req.param('id'); // string — inferred from ':id'
const userId = c.get('userId'); // string — inferred from Variables
const db = c.env.DB; // D1Database — inferred from Bindings
const user = await db.prepare('SELECT * FROM users WHERE id = ?')
.bind(id)
.first();
return c.json({ user, requestedBy: userId });
});
export default app;
// itty-router TypeScript — minimal type support
import { Router, IRequest } from 'itty-router';
interface Env {
DB: D1Database;
KV: KVNamespace;
}
const router = Router();
// IRequest gives you params typing, but no route-level inference
router.get('/users/:id', async (req: IRequest, env: Env) => {
const id: string = req.params.id; // Works — basic params
// env.DB is typed — if you typed Env correctly
// But no Variables concept, no middleware type propagation
const user = await env.DB.prepare('SELECT * FROM users WHERE id = ?')
.bind(id)
.first();
return Response.json(user);
});
itty-router's TypeScript support covers the basics: route params are strings, the IRequest type extends the standard Request. But there's no concept of typed Variables (values set by middleware), no inference of path parameter names from route strings like /users/:id, and no Bindings type propagation through the context object. For serious TypeScript codebases, Hono's type system is considerably more useful.
The practical consequence of this gap shows up when working with Cloudflare Workers bindings. In Hono, you define your Bindings type once and c.env.DB, c.env.KV, and c.env.API_SECRET are all fully typed throughout every handler and middleware. In itty-router, you pass the env object to each handler separately and must manually type it in each handler signature or create a shared interface and import it. For a 2-route Worker this is trivial; for a 20-route API it becomes error-prone maintenance work.
Error Handling
Unhandled errors in edge Workers cause silent failures or 500 responses with no useful debugging information unless you handle them explicitly.
// Hono — built-in error handling with typed exceptions
import { Hono, HTTPException } from 'hono';
const app = new Hono();
// Global error handler
app.onError((err, c) => {
if (err instanceof HTTPException) {
return c.json({ error: err.message }, err.status);
}
console.error('Unhandled error:', err);
return c.json({ error: 'Internal server error' }, 500);
});
// 404 handler
app.notFound((c) => c.json({ error: 'Not found' }, 404));
app.get('/users/:id', async (c) => {
const user = await getUser(c.req.param('id'));
if (!user) throw new HTTPException(404, { message: 'User not found' });
return c.json(user);
});
// itty-router — manual error handling everywhere
import { Router } from 'itty-router';
const router = Router();
router.get('/users/:id', async ({ params }, env) => {
try {
const user = await getUser(params.id);
if (!user) {
return new Response(JSON.stringify({ error: 'User not found' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
});
}
return Response.json(user);
} catch (err) {
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
});
export default {
fetch: (request, env, ctx) =>
router
.handle(request, env, ctx)
.catch((err) => new Response('Error', { status: 500 })),
};
Hono's HTTPException and app.onError() give you a centralized place to handle errors with proper status codes and response formatting. With itty-router, you either wrap every handler in try/catch or attach a global .catch() to the router.handle() call — which gives you less control over the response format.
Real-World Worker Example
A realistic Cloudflare Worker reads from KV or D1, validates input, and returns JSON. Here's how both frameworks handle it:
// Hono — KV read with Zod validation, full TypeScript
import { Hono } from 'hono';
import { zValidator } from '@hono/zod-validator';
import { z } from 'zod';
const app = new Hono<{
Bindings: { USER_KV: KVNamespace };
}>();
app.get('/users/:id', async (c) => {
const id = c.req.param('id');
const cached = await c.env.USER_KV.get(`user:${id}`, 'json');
if (cached) return c.json(cached);
const user = await fetchUserFromDB(id);
if (!user) return c.json({ error: 'Not found' }, 404);
await c.env.USER_KV.put(`user:${id}`, JSON.stringify(user), {
expirationTtl: 300,
});
return c.json(user);
});
app.post('/users',
zValidator('json', z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
})),
async (c) => {
const { name, email } = c.req.valid('json');
const user = await createUser({ name, email });
return c.json(user, 201);
}
);
export default app;
// itty-router — same functionality, more manual code
import { Router } from 'itty-router';
const router = Router();
router.get('/users/:id', async ({ params }, env) => {
const cached = await env.USER_KV.get(`user:${params.id}`, 'json');
if (cached) return Response.json(cached);
const user = await fetchUserFromDB(params.id);
if (!user) {
return new Response(JSON.stringify({ error: 'Not found' }), { status: 404 });
}
await env.USER_KV.put(`user:${params.id}`, JSON.stringify(user), {
expirationTtl: 300,
});
return Response.json(user);
});
router.post('/users', async (request, env) => {
const body = await request.json().catch(() => null);
if (!body) return new Response('Invalid JSON', { status: 400 });
const { name, email } = body;
if (!name || typeof name !== 'string' || name.length > 100) {
return new Response(JSON.stringify({ error: 'Invalid name' }), { status: 400 });
}
if (!email || !email.match(/^[^\s@]+@[^\s@]+\.[^\s@]+$/)) {
return new Response(JSON.stringify({ error: 'Invalid email' }), { status: 400 });
}
const user = await createUser({ name, email });
return new Response(JSON.stringify(user), {
status: 201,
headers: { 'Content-Type': 'application/json' },
});
});
export default { fetch: router.handle };
The itty-router version is functional but requires manual JSON error handling, manual validation, and manual Content-Type headers throughout. For a single-route Worker these are minor annoyances. For a real API with 15+ routes, the cumulative overhead is significant — and the manual validation code is much more likely to have bugs than Hono's zValidator. Manual regex email validation has a long history of being implemented slightly wrong. Manual null checking misses edge cases. Hono's Zod integration eliminates an entire category of input-handling bugs by using a well-tested validation library rather than ad-hoc checks.
Package Health
| Metric | Hono | itty-router |
|---|---|---|
| Weekly downloads | ~1.8M | ~200K |
| GitHub stars | ~22K | ~3K |
| Last release | Very active | Niche, smaller team |
| TypeScript | Built-in, full generics | Basic typings |
| Maintained | Yes (Yusuke Wada + community) | Yes (small team) |
| Runtime support | Workers, Node, Bun, Deno | Workers-focused |
| Middleware | First-party suite | None |
Hono's download volume and star count reflect its position as the default framework for edge-native TypeScript development in 2026. Hono's creator, Yusuke Wada, has been actively expanding the framework to cover more runtimes and use cases — it now ships adapters for Node.js, Bun, Deno, AWS Lambda, and Vercel Edge, in addition to Cloudflare Workers. This multi-runtime support is what differentiates Hono from edge-specific micro-frameworks: it's increasingly the choice for any TypeScript API project, not just Workers.
itty-router serves a specific niche — developers who want the absolute minimum router for single-purpose Workers — and does that job well within its scope. Both are maintained, but Hono's ecosystem is growing much more rapidly. Third-party packages are building on Hono's middleware interface, and the @hono/ organization on npm ships official middleware for common needs. itty-router's ecosystem is essentially just the core library.
When to Choose
Choose itty-router when:
- Bundle size is genuinely the primary constraint (near the 1MB Workers limit)
- Building a single-purpose Worker with 1-3 routes (proxy, redirects, simple webhook)
- You don't need middleware and are comfortable implementing auth/validation manually
- Zero-dependency philosophy and minimal footprint is a hard requirement
Choose Hono when:
- Building any production-grade API on edge runtimes
- TypeScript type safety matters to your team
- You need middleware: auth, CORS, validation, caching
- The project will grow beyond a handful of routes
- You might deploy to multiple runtimes (Workers + Node.js + Bun)
- You want a well-supported ecosystem with first-party middleware
For the vast majority of Workers projects, the decision is straightforward: Hono provides the middleware, TypeScript, and error handling that real applications need, at a bundle size that is still negligible in the context of Workers' limits. itty-router remains the right tool for developers who truly need the minimum — a single-purpose proxy, a redirect handler, a webhook receiver with one route — and understand they're trading DX for footprint.
For the full side-by-side package health metrics, see the Hono vs itty-router comparison page. If you're coming from a Node.js background and evaluating edge options, the Express vs Hono 2026 article covers the broader context of why edge-native frameworks matter. The Hono package page tracks release cadence and download trends as the framework continues to grow.
See the live comparison
View hono vs. itty router on PkgPulse →