Architecture Guide
How tzin processes requests from start to finish.
Request Pipeline
Request
│
▼
┌─────────────────────────┐
│ Router (radix trie) │ Match path + method → extract params
└──────────┬──────────────┘
│
▼
┌─────────────────────────┐
│ Validation │ TypeBox checks params/query/body/headers
└──────────┬──────────────┘
│
▼
┌─────────────────────────┐
│ Middleware (onion) │ Pre-processing, auth, DI, CORS...
└──────────┬──────────────┘
│
▼
┌─────────────────────────┐
│ Handler │ Business logic, return typed response
└──────────┬──────────────┘
│
▼
┌─────────────────────────┐
│ Response │ JSON serialization, status, headers
└──────────┬──────────────┘
│
▼
ResponseContract-First Design
A contract is a plain object — not a builder chain. This gives O(1) type inference per endpoint.
// This is data, not a method call
const getUser = contract({
method: 'GET',
path: '/users/:id',
params: t.Object({ id: t.String() }),
responses: {
200: t.Object({ id: t.String(), name: t.String() }),
404: t.Object({ error: t.String() }),
},
})Why flat objects?
- Builder chains (
app.get(...).post(...)) require one giant expression - Separated statements lose type inference (
typeof appdrops routes) - Flat objects → shallow types → constant cost per endpoint
Type Inference
tzin grows ~130 types per endpoint. Hono grows ~1,100.
N=20: tzin 4,851 types vs Hono 72,368 types
N=100: tzin 15,779 types vs Hono 111,952 types
N=300: tzin 43,099 types vs Hono 210,912 typesThe difference: tzin uses indexed access on plain objects (shallow), Hono chains generic types (deep).
Router
Radix trie with O(path depth) lookup. 9.1M lookups/s in benchmarks.
/users/:id → node → {GET: handler}
/users/:id/posts → node → {GET: handler}
/health → node → {GET: handler}- Static routes beat param routes (tiebreak by specificity)
- 404: path not in trie
- 405: path exists but wrong method (returns
Allowheader per RFC 9110)
Middleware
Onion-style composition. Each layer calls next() to proceed.
Request → middleware1 → middleware2 → handler
Response ← middleware1 ← middleware2 ← handlerconst auth = middleware(async (ctx, next) => {
// Before handler
const user = await verifyToken(ctx.headers.authorization)
ctx.set('user', user)
const response = await next()
// After handler (can transform response)
return response
})Middleware can:
- Read/write context (
ctx.get,ctx.set) - Short-circuit (throw
HttpErroror return early) - Transform responses
- Add headers
Dependency Injection
Light DI via provide() + ctx.require().
// 1. Define a typed key
const dbKey = defineContext<Database>('db')
// 2. Seed it at app level
const app = createApp(routes, {
provides: [provide(dbKey, database)],
})
// 3. Use it in handlers/middleware
const handler = impl(getUser, async ({ params, ctx }) => {
const db = ctx.require(dbKey) // typed as Database
return { status: 200 as const, body: await db.getUser(params.id) }
})Request-scoped middleware can override values:
const overrideDb = middleware(async (ctx, next) => {
ctx.set(dbKey, testDatabase) // override for this request
return next()
})Runtime Adapters
All adapters implement the same pattern: convert platform request → fetch(req) → convert response.
Node.js
node:http → duck-type Request → app.fetch → write Response to socketOptimizations:
- Duck-typed request (skip undici
new Request()) - Fast response text (skip
res.text()drain) - Lazy AbortController (wired only if
ctx.signalis read) - Match cache (Map, capped at 10k entries)
Bun
Bun.serve → app.fetch → Bun ResponseNative WebSockets via Bun.serve({ websocket: ... }).
Cloudflare Workers
workerd fetch → app.fetch → workerd ResponseFor WebSockets: entire app runs inside a Durable Object (single I/O context).
Realtime Architecture
Channels
SSE down + POST up (works on every runtime):
Client Server
│ │
├─ GET /channels/lobby ──→ SSE stream
│ │
├─ POST /channels/lobby ─→ broadcast to all subscribers
│ {event, data} │
│ │
├─ POST /channels/lobby/heartbeat ─→ refresh presence TTL
│ │
└─ POST /channels/lobby/leave ──→ announce departurePresence
Phoenix-style TTL presence:
- Member joins → added to local members map
- Heartbeat → TTL refreshed
- Sweep (every 5s) → expired members removed,
presence_diffbroadcast - Multi-node → frames replicated over
MessageBus, each node merges
Multi-Node
Node A ←── MessageBus ──→ Node B
│ │
Hub A Hub B
│ │
Subscribers SubscribersBus interface (2 methods):
interface MessageBus {
publish(channel: string, message: string): Promise<void>
subscribe(channel: string, handler: (message: string) => void): Promise<void>
}Implementations: Redis (ioredis), Postgres (LISTEN/NOTIFY), Cloudflare Durable Objects.
AI-Native Features
MCP Server
Contracts → MCP tools automatically:
startStdioMcp(app) // stdio transport
createApp(routes, { mcp: true }) // HTTP transport at POST /mcptools/listreturns JSON SchemainputSchemafrom contracts (TypeBox = JSON Schema)tools/calldispatches through validation, middleware, and DI- Errors surface as
isErrorresults (not thrown)
LLM.txt
GET /llms.txt → index of endpoints
GET /llms-full.txt → index + inline JSON SchemaGenerated from contracts — no manual maintenance.
Performance
Hot Path
- Parse URL + match route (trie)
- Validate sections (TypeBox
Value.Check, ~0.05µs) - Run middleware chain
- Execute handler
- Serialize response (JSON.stringify + fast text)
Why 0.9x of Hono?
In-process dispatch is equal (~127k vs 118k req/s). The gap is the Node adapter:
- Undici
new Request()costs ~13µs under load res.text()costs ~7k req/s marginal- Fixed with duck-request + fast response text
Why Not 1.0x?
Remaining ~1.5x gap: Headers allocation, async scheduling overhead, middleware composition cost. Not CPU-bound (profiling shows 44% idle under load).