Honoフレームワーク実践記:Web標準フレームワークを試して

HonoをCloudflare Workers上で動かした実践記録:ルーティング、型安全RPC、ベンチマーク、そして語られないハマりどころを解説。

Honoフレームワーク実践記:Web標準フレームワークを試して

短い答え: HonoはWeb標準で作られた軽量(数KB)なWebフレームワークで、ブラウザのfetch()と同じRequest/Responseを使う。だから同じコードがCloudflare Workers、Bun、Deno、Node、AWS Lambdaで動く。システムアーキテクトが注目すべき理由は「Expressより速い」からではなく、ランタイムへの縛りを外し、ルートからクライアントまで型をそのまま流せるからだ。

TL;DR(エグゼクティブサマリー)

  • 課題: 当サイトのAPI層(と社内のいくつかのマイクロサービス)はCloudflare上で動いている。Workersに載せられる十分小さいバンドルとミリ秒のコールドスタートが必要で、なおかつOpenAPIを立ててコード生成する手間なしに端から端まで型安全にしたかった。
  • 解決策: その層をHonoで書き直した。ミドルウェア(logger、CORS、JWT…)はコアに同梱で、5つのパッケージをnpm installする必要はない。RPCモードはexport type一行でサーバーからクライアントへ型を共有する。
  • 結果: 約15KB未満のバンドル、一桁ミリ秒のコールドスタート、ランタイム変更でもほぼコード修正なし。代償は、Honoの型が「楽観的」であること——ミドルウェアの順序を正しく保つのは利用者の規律に委ねられる。

再定義:システムアーキテクチャの視点でHonoとは

多くの紹介記事はHonoを「エッジ向けの速いExpress」と呼ぶ。分かりやすいが、本質的な話を台無しにしている。

核心的な違いは速度ではなく互換レイヤーだ。ExpressはNode独自のreq/resの上に書かれている——10年以上の歴史を持ち、Node内にしか存在しないAPIだ。HonoはWeb標準(WinterCG)のRequest/Responseの上に書かれている——ブラウザでfetch()を呼ぶときに使うのと同じオブジェクトだ。

アーキテクチャ上の帰結は大きい。Honoはランタイムの「共通言語」しか話さないので、同じハンドラがCloudflare Workers、Bun、Deno、Node、Vercel Edge、Fastly、Lambdaでほぼ無修正で動く。アーキテクチャを一つのベンダーに縛らない。システムの3〜5年の寿命を考えなければならない人間にとって、「ランタイムに縛られない」ことは飾りではなく資産だ。

なぜHonoを試したか(実際の文脈)

当サイトはCloudflare Pagesにデプロイしており、サーバーレス関数がいくつかある(例えばPageSpeed Insightsを呼ぶプロキシエンドポイント——APIキーをクライアントに漏らさないため)。関数の数が増えると、極めて現実的な問題が二つ現れた。

  1. コールドスタートとCPUバジェット。 WorkersはCPU時間で課金され、ハードな上限がある。重いNodeフレームワークを載せると、動かないか不快なコールドスタートを食う。ほぼ瞬時に立ち上がるものが必要だった。
  2. ネットワーク境界で型が切れる。 フロントが関数を呼ぶが、リクエスト/レスポンスの形は手作業で同期する必要がある。エンドポイントの形を一つ変えると呼び出し側を手で直すことになり、TypeScriptは助けてくれない。

Honoはこの両方を同時に解決するので、API層をHonoに移した。

実コード:ルーティングとミドルウェア

すぐに「軽い」と感じたのがこの部分だ。ミドルウェアは個別にインストールする山ではなく、コアに入っている。

import { Hono } from 'hono'
import { logger } from 'hono/logger'
import { cors } from 'hono/cors'

const app = new Hono()

app.use('*', logger())
app.use('/api/*', cors())

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

app.get('/api/posts/:id', (c) => {
  const id = c.req.param('id')      // string として推論済み
  return c.json({ id })
})

export default app                  // Workersでは export default だけで動く

app.listen()もserver.jsもない。Cloudflare Workersではexport default appで十分だ——Hono自身がランタイムの期待するfetchハンドラの契約をそのまま話すからだ。ミドルウェアは登録順に実行される。最初に登録したミドルウェアの「next()の前」が最初に走り、その「next()の後」が最後に走る。認証・キャッシュ・エラーハンドラを積むときにこの理解が効いてくる。

最大の魅力:コード生成なしの型安全RPC

ここに残った理由がこれだ。バリデータ付きでルートを定義し、そのルートをexport typeする。

import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'

const app = new Hono()

const route = app.post(
  '/posts',
  zValidator('json', z.object({ title: z.string(), body: z.string() })),
  (c) => {
    const data = c.req.valid('json')   // Zodで検証・型付け済み
    return c.json({ ok: true, id: 1 }, 201)
  }
)

export type AppType = typeof route

クライアント側ではhc(Hono Client)を作り、AppTypeを渡す。

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

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

const res = await client.posts.$post({
  json: { title: 'Hono', body: 'Workersで試した' },
})

if (res.ok) {
  const data = await res.json()   // data.id は推論済み、手動キャスト不要
}

コード生成のステップも、再ビルドが必要な生成済みOpenAPIファイルも、追加のサーバーもない。型は通常のimportを通じてルート定義から呼び出し側へ直接流れる。サーバーでスキーマを変えれば、クライアントで即座にTypeScriptが赤くなる。これはtRPCがやることと同じだが、Honoは独自プロトコルではなく素のHTTP/fetchの上でそれを行う。

実践比較:Hono vs Express vs Elysia

以下の数値は公開ベンチマークと直接の経験から集めたもの——絶対値は環境次第だが、相対的な順位は安定している。

基準 Express Hono Elysia
バンドル(minified) 約572 KB 約14〜15 KB 約15 KB
対応ランタイム 主にNode Workers, Deno, Bun, Node, Lambda, Vercel Bunネイティブ
コアミドルウェア 5つ以上を個別に コアに同梱 プラグイン方式
クライアント型安全 ほぼなし RPC hc()、非常に良い Eden Treaty、最良
スループット(Bun上) 最低 高い 最高
エッジでのコールドスタート 重い 一桁ミリ秒 主眼ではない

この表をアーキテクトとして読む:Elysiaは純粋なスループットと型の滑らかさで勝つが、Bunに強く縛られる。Honoは最もバランスの取れた位置にいる——速く、小さく、型も良く、そしてどこでも動く。Expressは伝統的なNodeモノリスには今も十分だが、エッジでは道具違いだ。

実践のハマりどころ:あまり事前に語られないこと

無料のフレームワークはない。私の時間を奪った三つを挙げる。

1. Honoの型は「楽観的」だ。 c.set()で設定した変数はアプリのグローバルスコープで型付けされるため、TypeScriptはその変数を設定するミドルウェアが一度も走っていないルートでもc.var.dbを読ませてしまう。IDEでは緑だが実行時にはundefinedになる。

app.use('/admin/*', async (c, next) => {
  c.set('db', createDb(c.env))
  await next()
})

app.get('/public', (c) => {
  const db = c.var.db   // TSエラーは出ないが、このルートは上のミドルウェアを通らない -> undefined
})

これはクリーンなDXのための意図的なトレードオフだが、規律を要求する。どのミドルウェアがどの変数を設定し、どのルートがそれを通るか——頭の中かフォルダ構造で自分で明確に保たねばならない。

2. ESMのみ。 Honoと@hono/node-serverはESM専用だ。プロジェクトにCommonJSが残っていると、実行時にモジュール解決エラーに当たる。古いコードベースに入れる前に知っておく価値がある。

3. HonoX(メタフレームワーク)はまだalpha。 Honoの上にNext風のフルスタックフレームワークを夢見るなら、HonoXがその方向だ——だがマイナーバージョンでも壊れうる。今日の本番では、安全なレシピはAPIにHono+別立てのフロントエンドで、HonoXは安定するまで追いかける。

ドキュメントの小さなコツで、私も正しいと確認したもの:ルートが多い場合は先にコンパイルしてからIDEを開くこと。さもないとRPCの型推論でエディタが重くなりうる。そしてRPCではc.notFound()を避け、ステータスコードを型に乗せるためc.json({...}, 404)を使う。

いつ使うべきで、いつ使わないべきか?

手短に言えば:HonoはエッジのAPI層やマイクロサービスに値し、tRPC/OpenAPIを背負わずに端から端まで型安全にしたいときに向く。逆に、問題なく動いている安定したExpressモノリスを、流行を追うためだけに移行するのはマイナス価値だ。

ただしこれは技術というよりコストとリスクの判断だ——特にExpressからHonoへは段階的に移行できないからだ。

再定義:フレームワーク採用は技術ではなくデリバリーの判断

きれいなベンチマークはエンジニアを5分で説得する。だが実際に採用の成否を決めるのは、書き直しのコスト、チームが習熟するまでの時間、エッジケースに当たったときのライブラリの揃い具合、そして2年後に保守できる人を採用できるか——すべてデリバリーの変数であり、速度テストの変数ではない。

言い換えれば「HonoはExpressより速い」は、既に動いているシステムに入れるべきかにはほとんど関係しない。以下が私の使う枠組みだ。

なぜ移行コストが最大の変数なのか

多くの人が見落とすのがここだ:同一アプリ内でExpressからHonoへルートを一つずつ移すことはできない。 ExpressはNodeのreq/resに、HonoはWeb標準のRequest/Responseに依存する。二つのリクエスト/レスポンスモデルは根本的に異なり、一つのコードベース内で「ラップして並走させる」きれいな道はない。

実務上の帰結:

  • すべてのルートハンドラは書き直しであって、修正ではない。
  • Express固有のミドルウェア(passport、multer、express-session…)はWeb標準互換版に置き換える必要がある——多くは@hono/*エコシステムに相当物があるが、すべてのケースで1対1ではない。
  • だから安全な戦略はサービス単位のストラングラーパターンだ:新しいサービス/マイクロサービスをHonoで作り、古い方は動かし続け、二つのフレームワークを一つのアプリに混ぜない。

つまり移行コストは「ルート数」に比例せず、塊ごとの書き直しの判断になる。大規模で安定したExpressコードベースでは、この数字は便益を上回ることが多く、答えはいいえだ。

5軸の判断マトリクス

私はHonoを5つの軸で採点する。大半が右の列に傾けば採用、左に傾けば見送り。

軸 「Honoを使わない」に傾く 「Honoを使う」に傾く
対象ランタイム 伝統的Node、長時間稼働サーバー エッジ(Workers/Deno/Vercel)、複数ランタイム
型安全の必要性 小さな社内API、クライアント少 サーバー→クライアントの型をコード生成なしで流したい
現状 大きく安定したExpressモノリス グリーンフィールド、または新規切り出しサービス
チーム/採用 Expressに習熟、Expressで採用 TSファースト、エッジ/Bunで構築
周辺リスク許容度 すべてのミドルウェアが既製品必須、自作ゼロ エコシステムに無ければ自作してもよい

読み方:現状と対象ランタイムの重みが最も大きい。移行コストを決めるからだ。この二つだけで大半のケースは採否を判定できる。

あまり見積もられない隠れコスト

移行以外に、ベンチマークに現れないコストがある:

  • 約4年の若いエコシステム。 Honoのコアは本番対応でダウンロードも急増(2026年には週あたり数千万)だが、Expressより10年若い。たまにExpressなら一行のnpm installで済むミドルウェアを自作することになる。その分のエンジニア工数を見込む。
  • 採用とバス係数。 Expressは候補者層が厚く10年分のドキュメントがある。Honoは新規のNodeバックエンドの大半を取りつつあるが、本番で動かした経験者の層はまだ小さい。問い:2年後に誰が保守するか?
  • エッジでの可観測性。 Workersで動かすとはエッジの可観測性モデルに入ること:ログ/メトリクスはプラットフォームのツールを通り、分散レート制限はしばしばDurable Objectsを要し、キャッシュはCache APIを使う。難しくはないが別の運用モデルで、学習コストがある。
  • ESMのみ。 旧インフラにCommonJSが残っていれば、その整理コストを数える。

3つのGo/No-Goシナリオ

シナリオA — グリーンフィールドのエッジAPI → 採用。 Cloudflare Workers/Deno上に新しいAPI/ゲートウェイ層やマイクロサービスを作り、ミリ秒のコールドスタートと端から端までの型安全が必要。Honoの本拠地だ。移行コストはなく利点だけ。採用する。

シナリオB — 安定したExpressモノリス → 見送り。 大きなExpressアプリがミドルウェア多数で問題なく稼働中。移行は全ハンドラの書き直しとミドルウェア置換を意味する——高コスト、高リスク、限界便益は低い。本当にエッジが必要なら、ストラングラーパターンでHonoの新サービスを切り出す。モノリスには触らない。

シナリオC — 本番フルスタックフレームワークが今すぐ必要 → 待ち。 Honoの上にNext風のもの(HonoX)が欲しい。方向は正しいが、HonoXはまだalphaでマイナーバージョンが壊れうる。今日の本番ではAPIにHono+別立てのフロントエンドで、HonoXが安定するまで追う。


本記事はエッジで動くアーキテクチャのクラスタに属する:ハードウェア/AIの視点は運用におけるエッジAIとコンピュータビジョン、大規模データのアーキテクチャの視点はサプライチェーンのビッグデータ:1億件超のレコードを参照。エッジのAPI層やインフラの再構築を検討中なら、プロジェクトストーリーやお問い合わせもどうぞ。

よくある質問

Honoフレームワークとはなにか、Expressとどう違うのか?

HonoはWeb標準で作られた軽量Webフレームワークで、ブラウザのfetch()と同じRequest/Responseオブジェクトを使う。そのため同じコードがCloudflare Workers、Bun、Deno、Node、Lambdaでそのまま動く。ExpressはNode独自のAPIに依存しており、エッジへ持ち出しにくい。

Honoを使うべきでないのはどんなときか?

体験は似ている:どちらもコード生成なしでサーバーからクライアントへ型を流す。違いは、tRPCがHTTPの上に独自プロトコルを構築するのに対し、Honoは素のHTTP/fetchを保つことだ——ルートは通常のRESTのままで、`hc()`クライアントは`fetch`に被せた薄い型レイヤーにすぎない。`curl`で普通のAPIとして叩けつつTypeScriptで型安全でいたいなら、Honoのモデルのほうが合う。

ExpressからHonoへ段階的に移行できるか?

同一アプリ内でルート単位の段階移行はできない。ExpressはNodeのreq/res、HonoはWeb標準のRequest/Responseという別モデルのため、ハンドラは書き直し、Expressミドルウェアは互換版に置き換える必要がある。安全な方法は新サービスや切り出したマイクロサービスでHonoを動かすことで、二つのフレームワークを一つのコードベースに混ぜないこと。

Honoは本番に十分成熟しているか?

できる。ただし別サービスのレベルであって、一つのアプリ内で混ぜない。実務的には、新サービス(または新しいエンドポイント群)をHonoで作り、リバースプロキシ/ゲートウェイの背後に置き、古いExpressアプリからトラフィックを徐々に抜く——ストラングラーパターンだ。二つのリクエスト/レスポンスモデルは直接互換ではないため、一つのプロセスに二つのフレームワークを混ぜるのは負債を招く。