Skip to content

Hono — Deep Dive senior

TL;DR — Hono (prononcer "honnô", "flamme" en japonais) est devenu en 2026 le framework dominant pour les déploiements edge : Cloudflare Workers, Vercel Edge Functions, AWS Lambda@Edge, Deno Deploy, Bun, Fastly Compute. Sa philosophie : un seul codebase qui tourne n'importe où, basé sur les Web Standards (Request, Response, Headers, URL) au lieu de l'API HTTP spécifique à Node. Hono offre aussi du JSX server-side minimaliste (utilisable comme alternative légère à React Server Components dans des contextes simples), un client RPC type-safe (hc), des validators (Zod adapter), et des perfs imbattables : ~100k req/s en local Node, et latence sub-milliseconde sur Cloudflare Workers grâce au V8 isolate model. Cette page couvre l'architecture, les patterns edge, JSX Hono, le RPC client, Zod, et pourquoi Hono a gagné la course des frameworks edge en 2026.

🧠 Mental model — ASCII + analogie

Hono est un framework Web Standards-first : tout est Request et Response (au sens fetch API), pas IncomingMessage / ServerResponse (Node-spécifiques). Cette décision architecturale est ce qui permet à Hono de tourner sur des runtimes complètement différents : Cloudflare Workers (V8 isolate, pas Node), Bun, Deno, Vercel Edge, et même Node (via un adapter).

                    ┌──────────────────────────────────────────┐
                    │            Hono app (single code)        │
                    │  app.get('/users', c => c.json([...]))   │
                    └──────────────────────────────────────────┘

              ┌─────────────────────────┼─────────────────────────┐
              ▼                         ▼                         ▼
   ┌─────────────────┐      ┌─────────────────────┐      ┌────────────────┐
   │ @hono/node-server│      │ Cloudflare Workers  │      │ Vercel Edge    │
   │ Node runtime    │      │ V8 isolate          │      │ V8 isolate     │
   │ 100k req/s      │      │ 0ms cold start      │      │ similar        │
   └─────────────────┘      └─────────────────────┘      └────────────────┘
              │                         │                         │
              ▼                         ▼                         ▼
   ┌─────────────────┐      ┌─────────────────────┐      ┌────────────────┐
   │      Bun         │      │   Deno Deploy       │      │  AWS Lambda    │
   │  ~150k req/s     │      │   isolate           │      │  (cold start)  │
   └─────────────────┘      └─────────────────────┘      └────────────────┘

Analogie : Express est un binaire Linux x86. Hono est un binaire WASM : il tourne partout où il y a un runtime compatible. Le code que tu écris pour Hono sur Cloudflare Workers est bit-pour-bit le même que sur Vercel Edge ou Node — tu changes juste l'entry point (Bun.serve, serve() Deno, export default Cloudflare, @hono/node-server).

L'autre métaphore : Hono est un microframework de niveau du Sinatra Ruby / Flask Python, mais avec une obsession pour la portabilité. Tu écris app.get('/path', handler) et c'est tout. Pas de DSL, pas de decorators, pas de modules.

┌────────────────────────────────────────────┐
│ const app = new Hono()                     │
│                                            │
│ app.use('*', logger())                     │
│ app.use('*', cors())                       │
│ app.use('/api/*', jwt({ secret }))         │
│                                            │
│ app.get('/api/users', (c) => c.json(...))  │
│ app.post('/api/users',                     │
│   zValidator('json', UserSchema),          │
│   (c) => c.json(c.req.valid('json')))      │
│                                            │
│ export default app                         │
└────────────────────────────────────────────┘

        ▼ (sur Cloudflare Workers)
        Request → matcher trie → middlewares → handler → Response

🛠️ Code minimal (ts)

ts
// app.ts — fonctionne sur Node, Bun, Deno, CF Workers, Vercel Edge
import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { cors } from 'hono/cors'
import { jwt } from 'hono/jwt'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'

type Env = { Bindings: { JWT_SECRET: string; DB: D1Database } }

const app = new Hono<Env>()

// Middlewares globaux
app.use('*', logger())
app.use('*', cors({ origin: 'https://app.example.com', credentials: true }))

// Auth scope
app.use('/api/*', async (c, next) => {
  const middleware = jwt({ secret: c.env.JWT_SECRET })
  return middleware(c, next)
})

// Schémas
const UserSchema = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(100),
})

// Routes
app.get('/health', (c) => c.json({ status: 'ok' }))

app.get('/api/users/:id', async (c) => {
  const id = c.req.param('id')
  const user = await c.env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(id).first()
  if (!user) return c.json({ error: 'not found' }, 404)
  return c.json(user)
})

app.post('/api/users', zValidator('json', UserSchema), async (c) => {
  const body = c.req.valid('json') // typé via Zod
  const result = await c.env.DB.prepare('INSERT INTO users (email, name) VALUES (?, ?) RETURNING *')
    .bind(body.email, body.name)
    .first()
  return c.json(result, 201)
})

// Error handler
app.onError((err, c) => {
  console.error(err)
  return c.json({ error: { message: err.message } }, 500)
})

// 404
app.notFound((c) => c.json({ error: 'not found' }, 404))

export default app

// Pour Node :
// import { serve } from '@hono/node-server'
// serve({ fetch: app.fetch, port: 3000 })

// Pour Bun : Bun.serve({ fetch: app.fetch })
// Pour Deno : Deno.serve(app.fetch)
// Pour CF Workers : export default app (le fichier devient le Worker)

Observations clés :

  • c est le Context : c'est c.req (requête), c.res (réponse), c.env (variables et bindings du runtime), c.executionCtx (waitUntil, passThroughOnException sur Cloudflare).
  • c.env.DB est un binding Cloudflare D1 (SQLite distribué). Sur Node, ce serait c.env.DB injecté par le bootstrap. Hono ne se mêle pas de la source du binding.
  • zValidator('json', schema) est un middleware qui valide et typifie le body. c.req.valid('json') retourne le body typé.
  • Le même fichier se déploie partout. Seul l'entry point change.

🎯 Patterns courants

Pattern 1 — Runtime-agnostic via Web Standards

Tout passe par Request / Response :

ts
app.get('/echo', async (c) => {
  const url = new URL(c.req.url)
  const ua = c.req.header('user-agent')
  return new Response(`hello ${ua}`, { headers: { 'content-type': 'text/plain' } })
})

Aucune référence à req.connection, req.socket, res.write (APIs Node-only). C'est ce qui permet la portabilité.

Pattern 2 — Bindings Cloudflare typés

Les Cloudflare Workers exposent des "bindings" (D1, KV, R2, Durable Objects, Queues, Vectorize). On les déclare via le génériques de Hono :

ts
type Env = {
  Bindings: {
    DB: D1Database
    CACHE: KVNamespace
    BUCKET: R2Bucket
    SESSION: DurableObjectNamespace
    QUEUE: Queue<MyMessage>
  }
  Variables: {
    user: { id: string; email: string }
  }
}

const app = new Hono<Env>()

app.get('/', async (c) => {
  const cached = await c.env.CACHE.get('key')
  const file = await c.env.BUCKET.get('path/to/file.pdf')
  // ...
})

Variables correspond au "context locals" : c.set('user', user) puis c.get('user') dans un middleware downstream.

Pattern 3 — JSX server-side avec hono/jsx

Hono embarque un runtime JSX minimaliste, utilisable côté serveur pour générer du HTML. C'est comparable à React Server Components dans l'esprit (composants serveur, streaming) mais beaucoup plus simple : pas de hydration, pas de client bundle, juste du HTML rendu.

tsx
// pages/Home.tsx
import { Hono } from 'hono'
import { jsxRenderer } from 'hono/jsx-renderer'

const app = new Hono()

app.use('*', jsxRenderer(({ children }) => (
  <html>
    <head><title>My App</title></head>
    <body>{children}</body>
  </html>
)))

app.get('/', (c) => {
  return c.render(
    <main>
      <h1>Hello</h1>
      <ul>{users.map(u => <li>{u.name}</li>)}</ul>
    </main>
  )
})

C'est parfait pour :

  • Apps marketing / landing / docs.
  • Backends qui servent du HTML simple (admin tools internes).
  • E-mails templates.
  • Edge SSR sans aller jusqu'à un framework full (Astro, Next).

À noter : pas d'hydration ni d'interactivité client. Pour ça, on combine avec HTMX ou Alpine.js.

Pattern 4 — Client RPC type-safe (hc)

hc (Hono Client) génère automatiquement un client TypeScript à partir de l'app, sans codegen, juste via inférence de types.

ts
// server.ts
const app = new Hono()
  .post('/users', zValidator('json', UserSchema), (c) => c.json({ id: '1', ...c.req.valid('json') }))
  .get('/users/:id', (c) => c.json({ id: c.req.param('id'), name: 'Alice' }))

export type AppType = typeof app
export default app

// client.ts
import { hc } from 'hono/client'
import type { AppType } from './server'

const client = hc<AppType>('https://api.example.com')

// Type-safe :
const res = await client.users.$post({ json: { email: '[email protected]', name: 'Alice' } })
const data = await res.json() // typé { id: string, email: string, name: string }

const user = await client.users[':id'].$get({ param: { id: '1' } })

C'est équivalent à tRPC dans l'expérience dev, mais reste sur HTTP REST classique (donc compatible avec n'importe quel client non-TS, cURL, OpenAPI tooling). Le frontend en TS profite de la type-safety, le reste du monde voit juste une API REST normale.

Pattern 5 — Middlewares composables

ts
const requireRole = (role: string) => async (c: Context, next: Next) => {
  const user = c.get('user')
  if (!user.roles.includes(role)) return c.json({ error: 'forbidden' }, 403)
  await next()
}

app.get('/admin', requireRole('admin'), (c) => c.json({ secret: 42 }))

Le pattern (opts) => middleware est identique à Express. La signature (c, next) est légèrement différente : on appelle await next() au milieu pour les middlewares qui font du "around" (mesurer le temps, gérer une transaction, etc.).

Pattern 6 — Streaming et SSE

ts
import { streamSSE } from 'hono/streaming'

app.get('/events', (c) => {
  return streamSSE(c, async (stream) => {
    while (true) {
      await stream.writeSSE({ data: JSON.stringify({ time: Date.now() }), event: 'tick' })
      await stream.sleep(1000)
    }
  })
})

Sur Cloudflare Workers, les streams natifs Web (ReadableStream) marchent et permettent du long-polling / SSE en edge.

Pattern 7 — Sub-apps et route()

ts
const users = new Hono().get('/', listUsers).get('/:id', getUser).post('/', createUser)
const orders = new Hono().get('/', listOrders).post('/', createOrder)

const app = new Hono()
  .route('/users', users)
  .route('/orders', orders)

Ce pattern est l'équivalent du Router Express, mais en plus, l'inférence de types du chaînage .route(...) est propagée jusqu'à hc<typeof app>() qui exposera client.users.$get, client.orders[':id'].$get, etc.

🔄 Versions — Hono majors, compat runtimes

Historique compact

  • Hono 1.x (2022) — release initiale, focus Cloudflare Workers.
  • Hono 2.x (2023) — adapters Node, Bun, Deno.
  • Hono 3.x (2023) — JSX, RPC client hc, validators (Zod adapter).
  • Hono 4.x (2024, toujours la major stable en 2026 — 4.12.x au moment d'écrire) — refactor du router (trie compilé RegExpRouter), perf x2, WebSocket helper (upgradeWebSocket), @hono/zod-openapi pour la génération OpenAPI, stabilisation de l'API context. La 4.x a gardé une cadence de releases mineures soutenue sans rupture majeure ; pas de v5 publiée à ce jour.

Compatibilité runtimes

RuntimeAdapterNotes
Node 18+@hono/node-serverWrapper qui fait le bridge IncomingMessage ↔ Request
Bun 1.0+natifBun.serve({ fetch: app.fetch })
DenonatifDeno.serve(app.fetch)
Cloudflare Workersnatifexport default app
Vercel Edgenatifexport runtime: 'edge'
AWS Lambdahono/aws-lambda (adapter intégré, handle(app))API Gateway v1/v2, ALB, Function URLs ; hono/lambda-edge pour Lambda@Edge
Fastly ComputenatifWeb Standards JS runtime
Netlify Edgenatifbasé sur Deno Deploy

Aucun autre framework Node n'a cette portabilité. Express est cloué à Node. Fastify aussi (en cours d'expérimentation edge mais loin d'être prêt).

Perf comparée 2026

Runtime + frameworkreq/slatence p99
Hono + Bun150k0.3 ms
Hono + Cloudflare Workers(mesure différente, isolate)5-15 ms total avec cold start négligeable
Hono + Node 2295k0.8 ms
Fastify + Node 2280k1 ms
Express + Node 2225k3 ms

(Benchmarks micro, "hello world" JSON. À prendre comme ordre de grandeur, pas vérité absolue.)

⚠️ Pitfalls — 9 erreurs classiques

1. Utiliser une API Node-specific dans un Worker

ts
import fs from 'node:fs' // ← KO sur Cloudflare Workers
app.get('/file', (c) => c.text(fs.readFileSync('./data.txt', 'utf8')))

Les Workers n'ont pas de filesystem. Pour lire des fichiers statiques, utiliser R2 (Cloudflare object storage) ou embarquer le contenu via import asset from './data.txt' (Wrangler le bundle).

2. Variables d'environnement sur Workers

process.env.FOO n'existe pas sur Workers (pas de process). Utiliser c.env.FOO, configuré dans wrangler.toml :

toml
[vars]
LOG_LEVEL = "info"

# secrets via : wrangler secret put DB_PASSWORD

3. Setting de status code

ts
return c.json({ error: 'bad' }, 400) // OK — idiomatique
c.status(400) // existe toujours en Hono 4, mais ne renvoie rien : à coupler avec c.body()/c.json()

Le deuxième argument de c.json/c.text/c.html est le status. La méthode c.status() chainée existe mais moins idiomatique.

4. Async middlewares qui oublient await next()

ts
app.use('*', async (c, next) => {
  console.log('before')
  next() // ← oublié await
  console.log('after') // exécuté trop tôt
})

Toujours await next(). Sans le await, l'ordre des hooks "around" casse.

5. RPC client : différence d'URL entre prod et dev

ts
const client = hc<AppType>('https://api.example.com') // hardcodé

En dev local : http://localhost:3000. Solution : injecter via env :

ts
const client = hc<AppType>(import.meta.env.VITE_API_URL ?? 'http://localhost:3000')

6. JSX et imports

hono/jsx n'est PAS React. Les hooks React, les Contexts, les Refs ne fonctionnent pas. C'est un mini-runtime JSX serveur-only. Pour mélanger React (client) et Hono JSX (serveur), tu fais du SSR séparé.

7. Cloudflare D1 et requêtes longues

Workers ont une limite de CPU time (10 ms par défaut, 50 ms en Bundled, 5 minutes en Unbound). Une grosse requête SQL D1 ou un appel HTTP lent peut faire timeout. Pour les jobs longs : Cloudflare Queues + Workers en consumer.

8. Cookies cross-domain et SameSite

Sur edge, le frontend et le backend peuvent être sur des domaines différents (app.com vs api.com). Pour que les cookies passent :

ts
import { setCookie } from 'hono/cookie'

setCookie(c, 'session', token, {
  httpOnly: true, secure: true, sameSite: 'None', domain: '.example.com', path: '/',
})

SameSite=None + Secure obligatoires en cross-domain.

9. Bundling et taille du Worker

Cloudflare Workers ont une limite de 1 MiB compressé sur le free tier (10 MiB sur paid). Importer une grosse librairie (axios, lodash entier, moment) tue ton bundle. Préférer les imports nommés, et auditer la taille avec wrangler deploy --dry-run.

🧪 Testing

Test avec app.request() (équivalent inject)

ts
import { test } from 'node:test'
import assert from 'node:assert'
import app from './app'

test('GET /health returns ok', async () => {
  const res = await app.request('/health')
  assert.strictEqual(res.status, 200)
  const body = await res.json()
  assert.strictEqual(body.status, 'ok')
})

test('POST /api/users validates', async () => {
  const res = await app.request('/api/users', {
    method: 'POST',
    headers: { 'content-type': 'application/json', authorization: 'Bearer <token>' },
    body: JSON.stringify({ email: 'nope', name: 'A' }),
  })
  assert.strictEqual(res.status, 400)
})

app.request() accepte une URL ou un objet Request, retourne une Response. Aucun port ouvert, in-process, super rapide. Parfait pour tests unitaires comme intégration.

Test avec bindings Cloudflare mockés

ts
const env = {
  DB: { prepare: () => ({ bind: () => ({ first: async () => ({ id: '1', name: 'mock' }) }) }) },
  JWT_SECRET: 'test',
}
const res = await app.request('/api/users/1', {}, env)

On passe le env mocké en troisième argument. Pour des tests plus fidèles, Miniflare ou @cloudflare/workers-types + unstable_dev (Wrangler) simulent un vrai Worker.

Vitest workspace pour multi-runtime

ts
// vitest.config.ts
export default defineConfig({
  test: {
    poolOptions: {
      workers: { wrangler: { configPath: './wrangler.toml' } },
    },
  },
})

Vitest + @cloudflare/vitest-pool-workers exécute les tests dans un vrai isolate Workers. Indispensable pour tester du code spécifique CF (Durable Objects, D1).

🎬 Cas d'usage concrets

Scénario 1 — Edge gateway e-commerce "ShopFlash" sur Cloudflare Workers

ShopFlash est un e-commerce français premium (mode et déco) qui sert 12 pays européens depuis un Next.js sur Vercel et un backend Hono déployé sur Cloudflare Workers (180 PoPs dans le monde). Le rôle du gateway Hono : (1) authentifier les utilisateurs via JWT signé, (2) router les requêtes vers le bon service (produits sur Postgres, panier sur Redis, recherche sur Algolia), (3) cacher agressivement les listes de produits dans Cloudflare KV, (4) injecter des headers de sécurité (CSP, HSTS) et faire du A/B testing edge.

Le code Hono fait 400 lignes pour le gateway entier. La latence p99 mesurée depuis Paris : 18 ms (vs 95 ms quand le gateway tournait sur ECS us-east-1). Pour un visiteur à Munich, Madrid ou Milan, la requête tape le PoP local — pas de round-trip transatlantique. Les bindings Cloudflare (KV, D1, R2) sont typés via Hono<Env> ce qui permet à l'IDE de proposer c.env.PRODUCTS_KV.get(key) avec autocompletion. L'équipe a aussi mis en place un middleware de feature flagging qui lit Cloudflare D1 (SQLite distribué) pour décider quelle variante de homepage servir à quel segment — décision prise au PoP, latence négligeable. Bénéfice business : +14 % de conversion sur les pages produits depuis le passage en edge gateway (corrélation directe avec la latence).

Scénario 2 — Fonction Vercel "LexFidens" pour signature DocuSign

LexFidens (le cabinet juridique du scénario Express) a une seconde app pour la signature électronique B2C des clients particuliers (mandat de vente immobilière). Cette app est une SPA Next.js déployée sur Vercel, et le backend tient en une seule Vercel Edge Function en Hono. Pourquoi pas le même backend Express ? Parce que cette app a une charge très spike (10 req/s en moyenne, 500 req/s pendant les notifications email de campagne notariale), et Vercel Edge scale à zéro et auto-scale à la milliseconde — pas de container à provisionner.

Le code Hono fait 180 lignes, vérifie la signature DocuSign HMAC sur le body brut (Hono expose c.req.raw qui est une Request Web Standard), stocke l'idempotency dans Vercel KV (Upstash Redis), et appelle le core back-office Express en us-east-1 via fetch. Latence end-to-end : 220 ms (la majorité dans le call back-office). Le gros gain par rapport à un Lambda Node : zéro cold start visible (V8 isolates), facturé à la milliseconde, et coût mensuel divisé par 5 vs un EC2 dédié.

Scénario 3 — SaaS RH "PaySimple Global" multi-région avec Hono sur Bun

PaySimple a lancé "PaySimple Global" pour servir des PME au Maroc, Sénégal et Côte d'Ivoire. Contraintes : les utilisateurs africains ont une latence très variable vers Paris (50-300 ms), et la connectivité peut être instable. Solution : un backend Hono déployé sur Bun (perf brute) sur des VPS Scaleway au Maroc et à Dakar, plus un cache Hono sur Cloudflare Workers devant.

Le même code Hono tourne sur Bun (POST/PUT/DELETE qui touchent la DB Postgres locale) et sur Cloudflare Workers (GET cachés en KV). L'équipe a écrit le code une seule fois et change uniquement l'entry point (Bun.serve vs export default app). Latence moyenne pour un utilisateur à Casablanca : 45 ms (vs 280 ms quand l'app tapait Paris). Bénéfice business : le NPS sur la région Afrique est passé de 32 à 58 en six mois. Le client RPC hc est utilisé par l'app React Native mobile pour avoir la type-safety end-to-end, ce qui a permis à l'équipe (2 devs mobile + 2 backend) d'itérer rapidement sans contrat OpenAPI à maintenir.

🛠️ Exemple end-to-end

Cas d'usage : "gateway ShopFlash sur Cloudflare Workers — auth JWT, cache produits dans KV avec stale-while-revalidate, lookup user dans D1, push event dans Queue, et SSE pour notifications panier abandonné".

ts
// src/index.ts — déployé sur Cloudflare Workers via wrangler
import { Hono } from 'hono'
import type { Context } from 'hono'
import { logger } from 'hono/logger'
import { cors } from 'hono/cors'
import { secureHeaders } from 'hono/secure-headers'
import { jwt, verify } from 'hono/jwt'
import { streamSSE } from 'hono/streaming'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'

type Env = {
  Bindings: {
    JWT_SECRET: string
    PRODUCTS_KV: KVNamespace
    USERS_DB: D1Database
    CART_EVENTS: Queue<CartEvent>
    PRODUCTS_API_URL: string
  }
  Variables: {
    user: { id: string; email: string; segment: 'standard' | 'premium' }
  }
}

type CartEvent = { type: 'item_added'; userId: string; productId: string; at: number }

const app = new Hono<Env>()

app.use('*', logger())
app.use('*', secureHeaders({ contentSecurityPolicy: { defaultSrc: ["'self'"] } }))
app.use('*', cors({ origin: ['https://shopflash.com', 'https://m.shopflash.com'], credentials: true }))

app.use('/api/*', async (c, next) => {
  const token = c.req.header('authorization')?.replace(/^Bearer /, '')
  if (!token) return c.json({ error: 'unauthenticated' }, 401)
  try {
    const payload = await verify(token, c.env.JWT_SECRET)
    const user = await c.env.USERS_DB
      .prepare('SELECT id, email, segment FROM users WHERE id = ?')
      .bind(payload.sub)
      .first<{ id: string; email: string; segment: 'standard' | 'premium' }>()
    if (!user) return c.json({ error: 'user not found' }, 401)
    c.set('user', user)
    await next()
  } catch {
    return c.json({ error: 'invalid token' }, 401)
  }
})

app.get('/api/products/:slug', async (c) => {
  const slug = c.req.param('slug')
  const cacheKey = `product:${slug}`
  const cached = await c.env.PRODUCTS_KV.get(cacheKey, 'json')
  if (cached) {
    c.executionCtx.waitUntil(refreshCache(c, slug, cacheKey))
    return c.json(cached, 200, { 'cache-control': 'public, max-age=60', 'x-cache': 'HIT' })
  }
  const fresh = await fetchProduct(c, slug)
  if (!fresh) return c.json({ error: 'not found' }, 404)
  await c.env.PRODUCTS_KV.put(cacheKey, JSON.stringify(fresh), { expirationTtl: 300 })
  return c.json(fresh, 200, { 'cache-control': 'public, max-age=60', 'x-cache': 'MISS' })
})

const AddToCart = z.object({ productId: z.string().uuid(), quantity: z.number().int().min(1).max(10) })

app.post('/api/cart/items', zValidator('json', AddToCart), async (c) => {
  const user = c.get('user')
  const body = c.req.valid('json')
  await c.env.CART_EVENTS.send({
    type: 'item_added',
    userId: user.id,
    productId: body.productId,
    at: Date.now(),
  })
  return c.json({ ok: true, segment: user.segment }, 201)
})

app.get('/api/notifications/stream', (c) => {
  return streamSSE(c, async (stream) => {
    const user = c.get('user')
    let counter = 0
    while (counter < 60) {
      await stream.writeSSE({
        event: 'cart_reminder',
        data: JSON.stringify({ userId: user.id, segment: user.segment, tick: counter }),
        id: String(counter),
      })
      await stream.sleep(1000)
      counter++
    }
  })
})

app.onError((err, c) => {
  console.error(err)
  return c.json({ error: { code: 'INTERNAL', message: err.message } }, 500)
})

export default app

// Note: on type le Context, pas `any`. `Context<Env>` propage les Bindings
// jusque dans les helpers — un staff engineer ne sacrifie jamais le typage
// sur les fonctions internes "parce que c'est juste un helper".
async function refreshCache(c: Context<Env>, slug: string, cacheKey: string) {
  const fresh = await fetchProduct(c, slug)
  if (fresh) await c.env.PRODUCTS_KV.put(cacheKey, JSON.stringify(fresh), { expirationTtl: 300 })
}

async function fetchProduct(c: Context<Env>, slug: string) {
  const res = await fetch(`${c.env.PRODUCTS_API_URL}/products/${slug}`)
  if (!res.ok) return null
  return res.json()
}

Le piège SWR du waitUntil que tout le monde rate

Le pattern stale-while-revalidate ci-dessus a un bug subtil de thundering herd : si 10 000 requêtes arrivent pendant que le cache est expiré, chacune déclenche son propre refreshCache → 10 000 appels à l'origine simultanés (cache stampede). En production, on protège ça avec un lock (un flag product:${slug}:refreshing dans KV avec un TTL court, posé en put conditionnel), ou mieux, en utilisant le Cache API de Cloudflare (caches.default) qui coalesce les requêtes au niveau du PoP. KV n'a pas de compare-and-swap natif : pour un vrai lock distribué, on passe par un Durable Object (single-threaded, fortement cohérent). Règle staff : le cache n'est pas le problème, c'est le miss synchronisé qui tue l'origine.

Ce gateway combine sept patterns Hono edge : (1) auth JWT via verify() natif Hono (pas de lib externe), (2) lookup user dans Cloudflare D1 (SQLite distribué, ~5 ms en local PoP), (3) cache produits avec stale-while-revalidate via executionCtx.waitUntil (le refresh tourne en arrière-plan, l'utilisateur reçoit la réponse cachée immédiatement), (4) validation Zod sur le body de mutation, (5) push d'event dans Cloudflare Queue (consommé par un autre Worker), (6) SSE via streamSSE (le client reçoit des notifications panier abandonné en temps réel), (7) gestion d'erreur centralisée via app.onError. Le bundle final compresse à 95 KB (sous la limite 1 MiB free tier), latence p99 mesurée à 22 ms depuis Paris, scale à 0 quand pas de trafic, et coût Workers : ~ 12 €/mois pour 50M de requêtes.


🔁 Quand utiliser / éviter

Utiliser Hono

  • Déploiement edge (Cloudflare Workers, Vercel Edge, Deno Deploy) : c'est LE framework de référence en 2026.
  • API qui doit tourner partout : si tu vises Node ET Bun ET Workers, Hono est le seul choix raisonnable.
  • BFF léger : un backend-for-frontend rapide à écrire et déployer en edge, sub-100ms partout dans le monde.
  • App marketing/docs SSR léger : avec hono/jsx + HTMX, on peut produire un site rapide sans React/Next.
  • Client RPC type-safe sans tRPC : hc couvre 80 % des cas d'usage de tRPC pour le tiers du coût d'apprentissage.

Éviter Hono

  • App full-stack avec SSR React : reste sur Next.js / Remix / Astro. Hono JSX n'est pas React.
  • Stack legacy Express/Fastify qui fonctionne : Hono ne te donnera pas un gain fonctionnel décisif si tu n'as pas besoin d'edge.
  • App avec énorme injection de dépendances : NestJS apporte plus de structure.
  • Domaines de logique métier complexes avec beaucoup de modules : Hono est volontairement minimaliste. Tu vas réinventer une structure de modules à la main.

Pourquoi Hono a gagné la course edge en 2026

Plusieurs frameworks ont tenté : Itty Router (trop minimaliste), Cloudflare Pages Functions (verrouillé CF), Next.js Edge runtime (lourd), Worktop (mort). Hono a gagné parce que :

  1. Web Standards-first dès le jour 1 (pas de migration legacy).
  2. Portabilité réelle entre Workers, Vercel, Deno, Bun, Node — pas juste promise.
  3. Écosystème mature : Zod adapter, JWT, OAuth, JSX, RPC client, OpenAPI génération.
  4. TypeScript first-class avec inférence puissante (hc<typeof app>()).
  5. Perf : ~150k req/s sur Bun, et latence négligeable en edge.
  6. Communauté japonaise + globale active, mainteneur principal (Yusuke Wada) très réactif.

Match-up rapide

CritèreHonoExpressFastifyNesttRPC
Edgeoui (référence)nonnonnonoui (avec Hono)
Nodeoui (adapter)natifnatifnatifdépend du transport
Bun/Denoouipartielnonnondépend
JSX serveuroui (minimal)nonnonnonnon
RPC clientoui (hc)nonnonnonoui (cœur du produit)
Perftrès hautemoyennehautemoyennedépend
Courbetrès faibletrès faiblefaibleélevéemoyenne

🧭 Comment un staff engineer raisonne sur Hono + l'edge

Choisir Hono, c'est surtout choisir un modèle d'exécution (l'isolate V8) et un modèle de données (data gravity à l'edge). Le framework est presque un détail ; les contraintes du runtime sont le vrai sujet.

Le modèle isolate vs le modèle process

DimensionNode (process / container)Cloudflare Workers (isolate)
Unité d'isolationprocess OS (~50–200 ms à démarrer)isolate V8 (~5 ms, souvent 0 perçu)
État en mémoirepersiste tant que le process vitéphémère — peut être recyclé à tout moment, jamais garanti entre requêtes
Globals / singletonsOK (pool DB, cache LRU)dangereux : pas de garantie de réutilisation, et l'I/O hors handler est interdit
setInterval / timers de fondOKinterdit hors d'une requête (pas de boucle d'événements "à toi")
CPU par requêteillimité (tu paies le serveur)budgété (10–50 ms, 5 min en Unbound)
Connexions sortantessockets TCP libresfetch HTTP only (pas de socket TCP brut sauf connect() TCP beta)

La conséquence pratique la plus violente : tu ne peux pas garder un pool de connexions Postgres en mémoire dans un Worker comme tu le ferais en Node. Chaque isolate est éphémère et il y en a des milliers. D'où l'écosystème edge-native : D1 (SQLite distribué), Hyperdrive (pooling managé devant ton Postgres), Neon/PlanetScale serverless drivers (qui parlent HTTP au lieu de TCP). Si quelqu'un te dit "on met juste pg dans le Worker", c'est un red flag : ça épuisera les connexions de l'origine sous charge.

Data gravity : le piège du compute rapide sur des données lointaines

Mettre le compute à l'edge (18 ms p99 à Paris) ne sert à rien si chaque requête fait 3 allers-retours vers une DB en us-east-1 (80 ms × 3). Le compute edge n'a de sens que si les données sont edge aussi, ou si tu peux répondre depuis le cache. Le mental model :

Edge compute SANS edge data            Edge compute AVEC edge data
─────────────────────────             ─────────────────────────
PoP Paris (5ms) ──► DB us-east (80ms)  PoP Paris (5ms) ──► KV/D1 local (3ms)
   ↑ tu as déplacé le compute             ↑ vraie victoire : 8ms total
   mais pas les données → 85ms+        (lecture cache/réplique régionale)

Le tableau de décision réel : read-heavy + cacheable → edge gagne énormément (KV, Cache API, D1 read replicas). Write-heavy + fortement cohérent → l'edge te complique la vie (le write doit aller vers une région primaire de toute façon) ; parfois mieux vaut une région centrale classique. C'est le Scénario 3 du fichier : writes sur Bun proche de la DB Postgres, reads cachés sur Workers.

Failure modes spécifiques edge

  • waitUntil qui dépasse le budget : le travail de fond (refreshCache, analytics) est tué quand l'isolate est recyclé. Ne mets jamais de logique critique dans waitUntil — c'est du best-effort.
  • Subrequest limit : un Worker free tier est limité à ~50 subrequests (fetch sortants) par requête, 1000 en paid. Un fan-out naïf (N+1 sur une API) explose ce plafond.
  • Cold start partiel : les isolates sont quasi-instantanés, mais le premier import d'un gros module (parse + compile du bundle) coûte. D'où l'obsession sur la taille du bundle (pitfall #9).
  • Pas de filesystem, pas de process, Date.now() "gelé" : sur Workers, l'horloge n'avance pas pendant l'exécution synchrone (mitigation Spectre). Date.now() retourne la même valeur sur tout un bloc sync — ne l'utilise pas pour micro-benchmarker.
  • Logs : console.log part dans wrangler tail / Logpush, pas dans un fichier. L'observabilité edge passe par du structured logging + un sink (Logpush → R2/Datadog) et des Analytics Engine pour les métriques cardinales.

Observabilité en edge (ce qu'un junior oublie)

Pas de pm2 logs, pas de /var/log. Le minimum viable production :

ts
// Middleware d'observabilité : trace id + structured log + métrique
app.use('*', async (c, next) => {
  const traceId = c.req.header('cf-ray') ?? crypto.randomUUID()
  c.set('traceId', traceId)
  const start = Date.now()
  await next()
  // log JSON ligne unique → parsable par Logpush/Datadog
  console.log(JSON.stringify({
    traceId,
    method: c.req.method,
    path: new URL(c.req.url).pathname,
    status: c.res.status,
    ms: Date.now() - start,
    colo: c.req.raw.cf?.colo, // le PoP qui a servi
  }))
})

c.req.raw.cf expose la métadonnée Cloudflare (pays, colo/PoP, ASN, bot score) — utile pour le geo-routing et la sécurité, indisponible sur les autres runtimes (toujours undefined hors CF, à garder optionnel).

🏋️ Exercices

Chaque exercice est conçu pour être fait en TypeScript, testable avec app.request() (in-process, pas de port). Progression : implémenter → durcir pour la prod → casser puis réparer.

Exercice 1 — RPC type-safe end-to-end (implémenter)

Objectif : construire une mini-API notes (GET /notes, GET /notes/:id, POST /notes avec validation Zod) et consommer-la via hc<typeof app>() côté client avec zéro any, le tout typé jusqu'au await res.json().

Indice/Solution : chaîne tes routes (new Hono().get(...).post(...)) sans les casser en variables intermédiaires — l'inférence hc dépend du chaînage. Exporte export type AppType = typeof app. Côté client, const r = await client.notes[':id'].$get({ param: { id } }) puis vérifie que await r.json() est bien typé. Piège à découvrir : un middleware non typé en amont peut élargir le type à unknown.

Exercice 2 — Cache stale-while-revalidate sans stampede (production-grade)

Objectif : implémenter un cache produit en KV avec SWR, mais protégé contre le cache stampede : sous 1000 requêtes concurrentes sur une clé expirée, au plus un refresh d'origine doit partir.

Indice/Solution : pose un lock product:${slug}:lock dans KV avec expirationTtl: 5 avant de lancer refreshCache, et vérifie son absence avant de déclencher le refresh dans waitUntil. Discute la race résiduelle (KV n'est pas linéarisable, réplication eventually-consistent → deux PoP peuvent poser le lock simultanément). Solution réellement correcte : un Durable Object par clé chaude qui sérialise les refresh. Mesure : compte les appels fetchProduct avec un compteur mocké en test.

Exercice 3 — Middleware auth + RBAC + propagation de contexte (production-grade)

Objectif : un middleware auth() qui vérifie un JWT, charge l'utilisateur, le met dans c.set('user', ...) typé via Variables, plus un requireRole('admin') composable. Bonus : rate-limiting par utilisateur (sliding window) backé par KV ou Durable Object.

Indice/Solution : type Variables: { user: User } dans Env pour que c.get('user') soit typé sans cast. Pour le rate-limit, clé rl:${userId}:${windowStart}, INCR-like via get + put (attention : non atomique en KV → pour un vrai sliding window atomique, Durable Object avec storage.transaction). Teste le 429 avec app.request() en bouclant N+1 fois.

Exercice 4 — Portabilité réelle : un code, trois runtimes (production-grade)

Objectif : prendre l'app de l'exercice 3 et la faire tourner sans modification du fichier app sur Node (@hono/node-server), Bun (Bun.serve), et un test Workers (@cloudflare/vitest-pool-workers). Isole tout ce qui est runtime-spécifique dans des entry points distincts.

Indice/Solution : la clé est de ne jamais importer node:*, process.env, fs, ni de tenir un singleton en mémoire dans app.ts. L'accès à l'environnement passe toujours par c.env (injecté différemment par chaque entry point). Crée entry.node.ts, entry.bun.ts, et un test isolate. Casse-tête à résoudre : sur Node, c.env est vide par défaut — il faut le peupler via l'option env de serve ou un middleware d'injection.

Exercice 5 — Break-then-fix : le Worker qui timeout sous charge (break → fix)

Objectif : on te donne un endpoint qui agrège 30 produits en faisant await fetchProduct() en série dans une boucle. Sous charge, il timeout (CPU/wall-clock budget) et explose le subrequest pattern. Diagnostique et répare.

Indice/Solution : le bug est la sérialisation (for ... await séquentiel → 30 × latence). Fix : Promise.all / Promise.allSettled pour paralléliser, mais attention au subrequest limit (50 free) → batch par paquets de N avec un petit pool de concurrence. Discute pourquoi Promise.all peut faire échouer toute la requête sur un seul reject (→ allSettled + dégradation gracieuse). Vérifie le fix en comptant la latence simulée dans un mock.

Exercice 6 — Break-then-fix : la fuite de pool Postgres en edge (break → fix)

Objectif : on te donne un Worker qui importe pg et ouvre une connexion Postgres par requête. En prod, l'origine atteint max_connections et tombe. Comprends pourquoi et propose l'architecture correcte.

Indice/Solution : chaque isolate est éphémère et il y en a des milliers → des milliers de connexions TCP simultanées vers Postgres. Les pools en mémoire ne se partagent pas entre isolates. Fix correct : Hyperdrive (pooling/proxy managé Cloudflare) ou un driver serverless HTTP (Neon/PlanetScale) qui multiplexe au-dessus de HTTP. Explique pourquoi c.executionCtx.waitUntil(conn.end()) ne sauve pas le problème (le coût est l'ouverture, pas l'oubli de fermeture).

🎤 En entretien

Q : Pourquoi ne peut-on pas garder un pool de connexions DB en mémoire dans un Cloudflare Worker, alors que c'est la norme en Node ? R senior : parce que le modèle d'exécution est l'isolate éphémère, pas le process long-vivant. Il existe des milliers d'isolates, chacun peut être recyclé à tout moment, et les globals ne sont pas garantis réutilisés entre requêtes — un pool en mémoire devient des milliers de connexions TCP vers l'origine. La réponse edge-native est un proxy de pooling managé (Hyperdrive) ou un driver serverless HTTP (Neon/PlanetScale), pas un pool applicatif.

Q : Comment Hono peut-il tourner à l'identique sur Node, Bun, Deno et Workers ? R senior : parce qu'il est bâti sur les Web Standards (Request/Response/Headers/URL/fetch) au lieu de l'API Node (IncomingMessage/ServerResponse). Le cœur ne touche jamais d'API runtime-spécifique ; chaque plateforme fournit juste un entry point (serve, Bun.serve, Deno.serve, export default) et injecte l'environnement via c.env. Sur Node, @hono/node-server fait le pont IncomingMessage ↔ Request.

Q : hc (RPC Hono) vs tRPC — quand choisir quoi ? R senior : hc infère le client à partir de typeof app sans codegen mais reste du HTTP REST classique : ton API est consommable par cURL, OpenAPI, clients non-TS. tRPC offre une DX plus riche (procédures, transformers, subscriptions, batching) mais lie fortement client et serveur via son protocole propre. Choisis hc quand l'API doit aussi servir des clients non-TS ou rester RESTful ; tRPC quand c'est un monorepo TS de bout en bout et que tu veux le maximum de DX.

Q : Un stakeholder veut "tout passer en edge pour la latence". Quelle est ta première question d'architecte ? R senior : "Où sont les données, et la charge est-elle read-heavy cacheable ou write-heavy fortement cohérente ?" Déplacer le compute à l'edge sans déplacer (ou cacher) les données ne fait que rajouter un hop devant un round-trip lointain — la latence empire. L'edge gagne sur le read-heavy cacheable (KV, Cache API, read replicas) ; sur le write-heavy cohérent, le write doit rejoindre une région primaire de toute façon, et une région centrale classique est souvent plus simple et moins chère.

🔗 Liens


Récap final. Hono est, en 2026, le seul framework Node-compatible vraiment runtime-agnostic. Tu écris un fichier app.ts et il tourne sur Cloudflare Workers, Vercel Edge, Bun, Deno, Node, AWS Lambda, Fastly — le même code, juste l'entry point change. Ce miracle est rendu possible par l'adhésion totale aux Web Standards (Request, Response). En plus de la portabilité, Hono offre des performances de pointe (~150k req/s sur Bun, sub-ms en edge), un système de middlewares à la Express, un JSX serveur minimaliste, un client RPC type-safe (hc) qui fait 80 % du boulot de tRPC, et un écosystème mature (Zod, JWT, OAuth, OpenAPI). À choisir dès que tu vises l'edge ou Bun. À éviter pour du SSR React lourd (reste sur Next/Remix) ou du domaine métier ultra-modulaire (Nest). Pour une nouvelle API en 2026, Hono est le choix par défaut si la latence globale et la portabilité comptent ; Fastify reste le choix par défaut si tu vises Node + perf raw ; Express reste le choix legacy.

Bibliothèque tech perso — Achref