Skip to content

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:

const app = new Hono()  // No generic — c.get()/c.set() are untyped

FIX:

import { AppEnv } from './types/env'
const app = new Hono<AppEnv>()

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:

// routes/projects.ts
const routes = new Hono()  // Missing AppEnv — loses parent's type context

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:

# Ingress annotation
traefik.ingress.kubernetes.io/service.serversscheme: h2c  # WRONG for streaming

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