深夜に Gemini を呼ぶだけの小さな社内ツールを直そうとして、node_modules が 300MB を超えているのを見たとき、正直にいうと少し気が滅入りました。やっていることは「アプリのレビューを 1 件受け取って要約を返す」だけです。それなのに Express とその周辺、TypeScript のビルド、ホットリロードのためのプロセスマネージャまで抱えていて、ちょっと直すたびに環境ごと立ち上げ直す時間が積み重なっていました。
2014 年から個人で iOS / Android アプリを作り続け、いまは壁紙や癒し系を中心に複数本を並行運用しています。累計のダウンロードは 5,000 万を超え、収益は AdMob が中心です。アプリ本体は Swift や Kotlin で書きますが、その裏で動く「小さな道具」— レビュー分析、メタデータ生成、画像のタグ付け — は、長いあいだ Node と Express の使い回しで済ませてきました。今回その一つを Bun と Hono に載せ替えてみて、想像以上に身軽になったので、判断の過程ごと残しておきます。
なぜ「もう一つのバックエンド」を足す決心がついたのか
最初に正直なところを書いておきます。私の本番バックエンドの大半は、いまも Cloudflare Workers で動いています。月数百円のコストで世界中のエッジに展開でき、アプリの数だけ Worker を増やしても運用が破綻しない、という事情があるからです。ですから「すべてを Bun に移す」話ではありません。
きっかけは、ローカルでの開発体験でした。Workers は本番では快適ですが、ローカルで Gemini の長いストリーミングを試し、レート制限のロジックを少しずつ調整する、という反復作業のときに、エミュレータの再起動やビルドの待ちが地味に効いてきます。手元で動かす小さなツールについては、「インストールも実行もテストも一つのバイナリで完結する」環境のほうが、個人開発の手数には合っているのではないか、と感じ始めていました。
Bun は実行環境であると同時にパッケージマネージャでありテストランナーでもあります。Hono はその上で動く、Web 標準(Request / Response)に素直なルーティングフレームワークで、しかも Cloudflare Workers でもそのまま動きます。つまり「ローカルは Bun で軽く回し、本番は同じコードを Workers に載せる」が成立する。これが、もう一つ環境を増やすだけの価値がある、と判断した理由です。
Node + Express から何が変わるか — 最小の Before / After
まず、いちばん地味で、いちばん効く違いから示します。Gemini を一回呼ぶだけのエンドポイントを、Express と Hono で並べてみます。
これまで書いていた Express 版は、こうでした。
// server.express.ts — これまでの書き方
import express from "express" ;
import { GoogleGenerativeAI } from "@google/generative-ai" ;
const app = express ();
app. use (express. json ());
const genai = new GoogleGenerativeAI (process.env. GEMINI_API_KEY ! );
app. post ( "/summarize" , async ( req , res ) => {
try {
const model = genai. getGenerativeModel ({ model: "gemini-2.5-flash" });
const result = await model. generateContent (req.body.text);
res. json ({ summary: result.response. text () });
} catch (e) {
res. status ( 500 ). json ({ error: "failed" });
}
});
app. listen ( 3000 , () => console. log ( "listening on 3000" ));
同じものを Hono で書くと、こうなります。
// server.ts — Bun + Hono 版
import { Hono } from "hono" ;
import { GoogleGenerativeAI } from "@google/generative-ai" ;
const app = new Hono ();
const genai = new GoogleGenerativeAI (Bun.env. GEMINI_API_KEY ! );
app. post ( "/summarize" , async ( c ) => {
const { text } = await c.req. json ();
const model = genai. getGenerativeModel ({ model: "gemini-2.5-flash" });
const result = await model. generateContent (text);
return c. json ({ summary: result.response. text () });
});
export default app; // Bun も Workers もこのまま受け取れる
行数の差はわずかですが、本質的な違いは末尾の export default app にあります。Express の app.listen はサーバを「起動する」コードで、その環境に縛られます。Hono は Request を受けて Response を返す関数を「公開する」だけなので、誰が起動するか(Bun のサーバ、Workers のランタイム、テスト内の app.request())を呼び出し側に委ねられます。この一点が、後で「同じコードを二つの場所で動かす」を可能にします。
実行は bun run server.ts だけです。ts-node も nodemon も要りません。bun --hot server.ts にすればホットリロードも標準で付いてきます。私の手元では、node_modules が Express 構成の約 300MB から 40MB 台まで落ち、bun install は 1 秒未満で終わるようになりました。数字そのものより、「直す→試す」の往復が体感で軽くなったことのほうが、毎日触る道具としては効きました。
ストリーミングを SSE で返す — モバイル側の体験を落とさない
要約や下書き生成のような用途では、全文が出来上がるまで待たせるとアプリ側の体験が一気に悪くなります。Gemini の generateContentStream を使い、サーバ送信イベント(SSE)でトークンを少しずつ流します。Hono には streamSSE ヘルパーがあり、Web 標準のストリームに素直に乗ります。
import { Hono } from "hono" ;
import { streamSSE } from "hono/streaming" ;
import { GoogleGenerativeAI } from "@google/generative-ai" ;
const app = new Hono ();
const genai = new GoogleGenerativeAI (Bun.env. GEMINI_API_KEY ! );
app. post ( "/summarize/stream" , ( c ) => {
return streamSSE (c, async ( stream ) => {
const { text } = await c.req. json ();
const model = genai. getGenerativeModel ({ model: "gemini-2.5-flash" });
const result = await model. generateContentStream (text);
let aborted = false ;
stream. onAbort (() => { aborted = true ; }); // クライアント切断を検知
for await ( const chunk of result.stream) {
if (aborted) break ; // 切断後は課金される生成を続けない
const piece = chunk. text ();
if (piece) await stream. writeSSE ({ data: piece, event: "token" });
}
if ( ! aborted) await stream. writeSSE ({ data: "[DONE]" , event: "end" });
});
});
export default app;
ここで実務上いちばん大事なのは stream.onAbort です。モバイルアプリでは、ユーザーが画面を閉じたりネットワークが切れたりして接続が途中で消えることが日常的に起きます。切断を検知せずに for await を回し続けると、誰も受け取らないトークンを生成し続け、その分だけ課金されます。onAbort でフラグを立て、ループの先頭で抜けるだけで、無駄な出力トークンを止められます。私はここを最初に入れ忘れていて、テスト中に何度も途中でアプリを閉じた結果、使ってもいない出力分の請求がじわじわ乗っていました。小さなツールほど、こういう穴を放置しがちです。
アプリ側(Swift)では URLSession の bytes(for:) で 1 行ずつ読み、data: 行を取り出して逐次表示します。event: end を受け取ったらストリームを閉じる、という素直な実装で十分動きます。
レート制限とコスト上限を「1 ファイル」で持つ
個人開発でいちばん怖いのは、バグや誰かのいたずらで API を呼び続け、月末に予想外の請求が来ることです。Gemini 側のクォータとは別に、自分のバックエンドの内側にも上限を持っておくと安心できます。Hono ではミドルウェアとして差し込めるので、ロジックを 1 ファイルに閉じ込められます。
// middleware/budget.ts — 1 日あたりの呼び出し回数とトークンに上限を引く
import type { MiddlewareHandler } from "hono" ;
const DAILY_CALL_LIMIT = 5000 ;
const counters = new Map < string , { day : string ; calls : number }>();
function today () {
return new Date (). toISOString (). slice ( 0 , 10 );
}
export const budgetGuard : MiddlewareHandler = async ( c , next ) => {
const key = c.req. header ( "x-app-id" ) ?? "default" ;
const now = today ();
const cur = counters. get (key);
if ( ! cur || cur.day !== now) {
counters. set (key, { day: now, calls: 0 }); // 日付が変わったらリセット
}
const entry = counters. get (key) ! ;
if (entry.calls >= DAILY_CALL_LIMIT ) {
return c. json ({ error: "daily budget exceeded" }, 429 );
}
entry.calls += 1 ;
await next ();
};
app.use("/summarize/*", budgetGuard) のように経路に貼るだけで、アプリごと(x-app-id ヘッダ)に 1 日の呼び出し回数を絞れます。メモリ上のカウンタなのでプロセス再起動で消えますが、ローカルツールや単一プロセスの小さなサーバには十分です。Workers のように複数インスタンスに分かれる本番では、ここを KV や Durable Objects に差し替える、という拡張の入口になります。ミドルウェアの外側のインターフェイスを変えなければ、保存先だけ後から入れ替えられるのが Hono の心地よいところです。
私の運用方針は単純です。コスト上限は「Gemini のクォータ」「自分のミドルウェア」「決済側のアラート」の三重で持つ こと。どれか一つに頼ると、必ずその一つが想定外の経路ですり抜けます。複数アプリを抱えていると、一本の暴走が他のアプリの予算まで食う形になりやすいので、x-app-id 単位の隔離はとくに効きます。
同じコードを Cloudflare Workers と Bun セルフホストで切り替える
ここが、今回いちばん身軽さを感じた部分です。先ほどの export default app のおかげで、起動方法を 2 種類用意するだけで、まったく同じアプリ本体を両方の環境で動かせます。
Bun でローカルに立てるなら、エントリは数行です。
// bun.ts — Bun セルフホスト用エントリ
import app from "./server" ;
Bun. serve ({ port: 3000 , fetch: app.fetch });
console. log ( "Bun listening on http://localhost:3000" );
Cloudflare Workers に載せるなら、エントリは export default app のままで、wrangler.toml を置くだけです。違いは環境変数の取り方くらいで、Bun.env を c.env 経由(Workers のバインディング)に寄せておけば、分岐は最小で済みます。
# wrangler.toml
name = "gemini-mini-backend"
main = "server.ts"
compatibility_date = "2026-06-01"
判断の手順としては、私はこう整理しています。
まずローカルを Bun で固める。 ストリーミングとレート制限のロジックを app.request() のユニットテストで詰める。ネットワークもエミュレータも要らないので反復が速い。
トラフィックの形を見る。 常時はほぼゼロで、たまにスパイクするだけなら Workers のエッジ+従量課金が圧倒的に有利。逆に、重いライブラリ(画像処理や独自バイナリ)に依存し、リクエストが連続するバッチ寄りの処理なら、Bun を 1 台のサーバで常駐させたほうが素直なこともある。
状態の置き場所だけ環境に合わせる。 カウンタやキャッシュは、Workers なら KV、Bun 常駐なら同一プロセスのメモリや SQLite。本体のルーティングは触らない。
つまり Bun と Workers は二者択一ではなく、「開発は Bun、配信は Workers、自前バイナリが要るときだけ Bun 常駐」という使い分けに落ち着きました。
テストを速く回す — ネットワークなしで検証する
Bun + Hono のもう一つの効きどころは、テストの速さです。export default app にしてあるおかげで、サーバを起動せずに app.request() でルートを直接叩けます。Gemini への呼び出しだけ差し替えれば、ネットワークもエミュレータも要らず、ミリ秒で結果が返ります。
// budget.test.ts — Bun のテストランナーで実行
import { expect, test } from "bun:test" ;
import app from "./server" ;
test ( "レート上限を超えたら 429 を返す" , async () => {
const call = () =>
app. request ( "/summarize" , {
method: "POST" ,
headers: { "content-type" : "application/json" , "x-app-id" : "test" },
body: JSON . stringify ({ text: "サンプル" }),
});
// 上限ぴったりまでは 200、超えた瞬間に 429 へ変わることを確認
let last = 200 ;
for ( let i = 0 ; i < 5001 ; i ++ ) last = ( await call ()).status;
expect (last). toBe ( 429 );
});
bun test で走らせると、外部 I/O を伴わないこの種のロジックは一瞬で終わります。私はレート制限やプロンプト組み立てのような「壊れると静かに課金が増える」部分を、こうしたユニットテストで先に固めてから本番へ出すようにしています。動くコードを書くこと以上に、壊れ方をテストで見えるようにしておくことのほうが、長く運用する道具では効いてきます。
観測とコストを 1 行で残す
最後に、本番で必ず欲しくなるのが「いま、いくら使っているか」です。Gemini のレスポンスには usageMetadata が含まれ、入力・出力・思考の各トークン数が取れます。これを構造化ログとして 1 行ずつ書き出しておくだけで、後からアプリ別・日別の消費を集計できます。
function logUsage ( appId : string , model : string , usage : any ) {
// JSON Lines で標準出力へ。後で jq や BigQuery に流し込める形にしておく
console. log ( JSON . stringify ({
ts: new Date (). toISOString (),
appId,
model,
inputTokens: usage?.promptTokenCount ?? 0 ,
outputTokens: usage?.candidatesTokenCount ?? 0 ,
totalTokens: usage?.totalTokenCount ?? 0 ,
}));
}
実運用では、この 1 行ログを Bun 常駐ならファイルに、Workers なら Logpush 経由でストレージに落とし、週に一度だけ集計しています。複数アプリを並行運用していると、「どのツールが地味にトークンを食っているか」は感覚では当てになりません。私の場合、要約系より画像のタグ付け系のほうが、1 リクエストあたりのトークンが数倍重いことが、この集計ではっきり見えました。数字を残しておくと、最適化すべき場所を勘で選ばずに済みます。
本番で踏んだ落とし穴と、その対処
身軽になった一方で、移行のあいだに何度かつまずきました。同じ轍を踏まないように、効いた対処だけ残します。
第一に、Bun.env と process.env の混在です。ローカルでは Bun.env が .env を自動で読みますが、Workers にはそもそも Bun が存在しません。私は環境変数の参照を一箇所のヘルパーに集約し、typeof Bun !== "undefined" で分岐させて解決しました。各所で直接 Bun.env を呼ぶと、Workers 側で実行時エラーになります。
第二に、ストリーミング時のバッファリングです。手元の Bun では即座に届くのに、リバースプロキシや一部の CDN を挟むと、SSE がまとまって遅れて届くことがありました。レスポンスに Cache-Control: no-cache と、必要に応じて X-Accel-Buffering: no を付けることで、途中段のバッファを抑えられました。アプリ側で「最初の 1 トークンが来るまでの時間」を計測しておくと、この種の劣化に早く気づけます。
第三に、依存ライブラリの互換性です。Bun は Node 互換 API をかなり広くカバーしますが、ネイティブアドオンに深く依存する一部のパッケージは、そのままでは動かないことがあります。Gemini の公式 SDK や軽量なユーティリティは問題ありませんでしたが、移行前に「本当に必要な依存はどれか」を棚卸ししておくと、移行そのものが軽くなります。今回はこの棚卸しの過程で、もう使っていない依存をいくつも捨てられました。
どこまで Bun に寄せ、どこを Workers に残すか
最後に、半月ほど両方を触ってからの、いまの私の立ち位置を書きます。結論からいえば、「毎日手で触る小さな道具」は Bun、「世界中のユーザーに常時配信するもの」は Workers という線引きに落ち着きました。
レビュー分析やメタデータ生成のように、自分が手元で回し、ときどき手を入れる類のツールは、Bun の「一つのバイナリで完結する」軽さがそのまま開発速度になります。一方、アプリから直接叩かれるエンドポイントは、エッジ配信とゼロスケールの恩恵が大きい Workers のままです。そして両者が export default app で同じ Hono アプリを共有できるので、片方で育てたミドルウェアをもう片方へ持っていくのが苦になりません。
個人開発は、道具の数だけ運用の重さが積み上がっていきます。だからこそ「軽さ」は機能の一つだと、私は考えています。今日できる小さな一歩としては、いま動いている Express の小さなツールを一つだけ選び、export default app の形に書き換えて Bun で起動してみることをおすすめします。本体のロジックは変えず、起動の口だけを差し替える。それだけで、次に同じツールを Workers へ運ぶ準備まで同時に整います。
同じように複数の小さな道具を抱えている方の、棚卸しのきっかけになれば嬉しいです。最後までお読みいただき、ありがとうございました。