Hono — Pitfalls¶
OWNER: urszula, maxim ALSO_USED_BY: sandro, boris, yoanna LAST_VERIFIED: 2026-03-26 GE_STACK_VERSION: 4.12.x
Overview¶
Known failure modes, anti-patterns, and fixes for Hono in GE services. Every backend agent MUST read this before writing or reviewing Hono code. Items are NEVER removed, only marked DEPRECATED if no longer applicable.
Context Typing Issues¶
Standalone Controller Functions Lose Type Inference¶
ANTI_PATTERN:
// DO NOT DO THIS
const getProject = (c: Context) => {
const id = c.req.param('id') // id is string | undefined — no route inference
return c.json({ id })
}
app.get('/projects/:id', getProject)
FIX:
// Option 1: Inline handler (preferred for simple routes)
app.get('/projects/:id', (c) => {
const id = c.req.param('id') // id is string — inferred from route
return c.json({ id })
})
// Option 2: createHandlers from factory (for complex handlers)
import { createFactory } from 'hono/factory'
const factory = createFactory<AppEnv>()
const [getProject] = factory.createHandlers((c) => {
const id = c.req.param('id') // typed correctly
return c.json({ id })
})
app.get('/projects/:id', getProject)
ADDED_FROM: hono-docs-2026-03, context typing documentation
Missing AppEnv Generic¶
ANTI_PATTERN:
FIX:
CHECK: Every new Hono() in GE code passes the <AppEnv> generic.
IF: Missing.
THEN: Context variables are any. Type bugs hide until production.
ADDED_FROM: hono-best-practices-2026-03, factory pattern documentation
Route File AppEnv Mismatch¶
ANTI_PATTERN:
FIX:
// routes/projects.ts
import { AppEnv } from '../types/env'
const routes = new Hono<AppEnv>() // Same AppEnv as parent app
ADDED_FROM: ge-internal-2026-03, type inference debugging
Middleware Ordering¶
Auth Before Error Handler¶
ANTI_PATTERN:
app.use('/api/*', authMiddleware) // Auth first
app.onError(errorHandler) // Error handler registered after
FIX:
app.onError(errorHandler) // Error handler registered first
app.use('/api/*', authMiddleware) // Auth throws HTTPException → caught by handler
CHECK: app.onError() is registered BEFORE any middleware that throws.
IF: Registered after.
THEN: app.onError() registration order does not actually matter for catching —
it catches regardless. BUT register it early by convention for readability.
ADDED_FROM: hono-middleware-docs-2026-03, onion model documentation
Middleware try/catch Instead of onError¶
ANTI_PATTERN:
app.use('*', async (c, next) => {
try {
await next()
} catch (err) {
// This may NOT catch all errors in some cases
return c.json({ error: 'Something went wrong' }, 500)
}
})
FIX:
app.onError((err, c) => {
// Always catches. Always runs. Use this.
if (err instanceof HTTPException) {
return c.json({ error: err.message }, err.status)
}
return c.json({ error: 'Internal error' }, 500)
})
ADDED_FROM: hono-github-discussion-3497, middleware error catching inconsistency
Forgetting await on next()¶
ANTI_PATTERN:
app.use('*', async (c, next) => {
const start = Date.now()
next() // Missing await!
console.log(`Took ${Date.now() - start}ms`) // Runs immediately, before handler
})
FIX:
app.use('*', async (c, next) => {
const start = Date.now()
await next() // Waits for handler + downstream middleware
console.log(`Took ${Date.now() - start}ms`) // Runs after handler
})
ADDED_FROM: ge-internal-2026-02, timing middleware bug
Async Error Handling¶
Unhandled Promise Rejection in Handlers¶
ANTI_PATTERN:
app.get('/api/data', async (c) => {
const data = await fetchExternalApi() // Throws on network error
return c.json({ data })
// No error handling — unhandled rejection crashes or returns 500 with no body
})
FIX:
// Option 1: Let onError handle it (preferred for most cases)
// Hono automatically catches thrown errors in async handlers
// Just make sure app.onError() is configured.
// Option 2: Handle specific errors in the handler
app.get('/api/data', async (c) => {
try {
const data = await fetchExternalApi()
return c.json({ data })
} catch (err) {
if (err instanceof ExternalApiError) {
throw new HTTPException(502, { message: 'Upstream API failed' })
}
throw err // Re-throw for onError to handle
}
})
CHECK: Handlers that catch errors MUST re-throw unknown errors. IF: You catch and swallow all errors. THEN: Silent failures. Monitoring blind spots.
ADDED_FROM: hono-error-handling-docs-2026-03
HTTPException.getResponse() Is Not Context-Aware¶
ANTI_PATTERN:
app.onError((err, c) => {
if (err instanceof HTTPException) {
return err.getResponse() // No access to context — can't add requestId
}
})
FIX:
app.onError((err, c) => {
if (err instanceof HTTPException) {
return c.json({
error: 'HTTP_ERROR',
message: err.message,
requestId: c.get('requestId'),
}, err.status)
}
})
ADDED_FROM: hono-httpexception-docs-2026-03, context-aware error responses
Streaming Response Gotchas¶
Streaming Does Not Actually Stream on Node.js¶
ANTI_PATTERN: Assuming stream() or streamSSE() streams incrementally on Node.js.
FIX:
The @hono/node-server adapter may buffer the entire response before sending.
This is a known issue. Workarounds:
- Use Transfer-Encoding: chunked explicitly
- Test streaming behavior with actual HTTP client (not app.request())
- For SSE: verify with curl --no-buffer against the running server
CHECK: If your service uses streaming, test it against a real HTTP server.
IF: You only test with app.request().
THEN: Streaming appears to work but may buffer in production.
ADDED_FROM: hono-github-issue-4154, node.js streaming behavior
Unhandled Exceptions in streamSSE Crash the Server¶
ANTI_PATTERN:
app.get('/api/events', (c) => {
return streamSSE(c, async (stream) => {
const data = await riskyOperation() // Throws — crashes entire server
await stream.writeSSE({ data: JSON.stringify(data) })
})
})
FIX:
import { streamSSE } from 'hono/streaming'
app.get('/api/events', (c) => {
return streamSSE(c, async (stream) => {
try {
const data = await riskyOperation()
await stream.writeSSE({ data: JSON.stringify(data), event: 'update' })
} catch (err) {
await stream.writeSSE({
data: JSON.stringify({ error: 'Stream error' }),
event: 'error',
})
}
stream.onAbort(() => {
// Clean up resources (remove listeners, close connections)
})
})
})
CHECK: Every streamSSE callback has try/catch inside.
IF: Exception escapes the callback.
THEN: Node.js process crashes. All connections on that pod drop.
ADDED_FROM: hono-github-issue-2164, SSE server crash
HTTP/2 Incompatibility with Streaming¶
ANTI_PATTERN: Using Hono streaming behind an HTTP/2 proxy without awareness.
FIX:
Hono sets Transfer-Encoding: chunked for streaming. HTTP/2 forbids this header.
In k3s with Traefik ingress, ensure the backend connection uses HTTP/1.1:
CHECK: If your service uses streaming or SSE. THEN: Verify the ingress does NOT upgrade backend connections to HTTP/2.
ADDED_FROM: hono-github-issue-4041, h2c transfer-encoding conflict
RPC Type Inference¶
Separate Route Declarations Break RPC Types¶
ANTI_PATTERN:
const app = new Hono<AppEnv>()
app.get('/projects', listHandler)
app.post('/projects', createHandler)
// RPC client: typeof app loses route type information
FIX:
const app = new Hono<AppEnv>()
.get('/projects', listHandler)
.post('/projects', createHandler)
// RPC client: typeof app has full route types
CHECK: Route files for RPC use method chaining.
IF: Routes use separate statements.
THEN: hc<typeof app>() returns a client with no typed routes.
ADDED_FROM: hono-rpc-docs-2026-03, type inference requirements
RPC Client Error Responses Are Untyped¶
ANTI_PATTERN:
const res = await client.api.projects.$get()
const data = await res.json()
// data is typed as success response even on 4xx/5xx
FIX:
async function callRpc<T>(
fn: () => Promise<Response>
): Promise<{ data: T; error: null } | { data: null; error: string }> {
const res = await fn()
if (!res.ok) {
const err = await res.json().catch(() => ({ message: 'Unknown error' }))
return { data: null, error: err.message ?? `HTTP ${res.status}` }
}
const data = await res.json() as T
return { data, error: null }
}
// Usage
const result = await callRpc<{ projects: Project[] }>(
() => client.api.projects.$get()
)
if (result.error) {
// Handle error
}
ADDED_FROM: hono-github-discussion-2279, RPC error typing limitation
Validation¶
Zod v4 .errors vs .issues¶
ANTI_PATTERN:
if (err instanceof ZodError) {
return c.json({ errors: err.errors }) // .errors does not exist in Zod v4
}
FIX:
if (err instanceof ZodError) {
return c.json({ issues: err.issues }) // .issues is the Zod v4 property
}
ADDED_FROM: ge-memory-2026-02, Zod v4 migration
Missing Content-Type Header on POST Tests¶
ANTI_PATTERN:
const res = await app.request('/api/projects', {
method: 'POST',
body: JSON.stringify({ name: 'Test' }),
// Missing Content-Type header!
})
// zValidator('json', ...) receives empty body → validation fails
FIX:
const res = await app.request('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Test' }),
})
ADDED_FROM: ge-internal-2026-03, test debugging
Cross-References¶
READ_ALSO: wiki/docs/stack/hono/index.md READ_ALSO: wiki/docs/stack/hono/middleware.md READ_ALSO: wiki/docs/stack/hono/patterns.md READ_ALSO: wiki/docs/stack/hono/checklist.md