「Bun は速いらしいけど、本番でClaude APIサーバーを動かしても大丈夫だろうか」— このセリフを2025年から数えきれないほど耳にしました。私自身、複数のサイドプロジェクトで Node.js から Bun へ段階的に移行してきた中で、ベンチマーク数値だけでは見えない本番運用上の判断軸があることに気づきました。
Bun は 1.2 系以降で安定性が上がり、Claude API のように外部 API を長く流し続けるワークロードにも使いやすくなりました。とはいえ、何も考えずに node を bun に置き換えるだけでは、移行の利点を引き出しきれません。逆に、思わぬところで詰まることもあります。
Bun + Claude API で本番品質のエッジAIサービスを構築するための実装パターンと、運用してみないと見えない落とし穴を整理しました。並列制限・切断検知・エラー分類・ヘッダ取得の4点は、Bun 1.4.2 と @anthropic-ai/sdk 0.133.0 で、模擬の上流サーバーを相手に実際に動かして確かめています。確かめた結果、私自身の最初の説明に誤りが三つ見つかりましたので、その経緯も書き残します。
なぜ今、Claude API サーバーで Bun を選ぶのか
Bun の強みは「速さ」だけではありません。Claude API のようなストリーミング中心のワークロードに対して、Bun が解決する具体的な課題を整理します。
ひとつ目は コールドスタートの短さ です。サーバーレス環境で Claude API サーバーを動かす場合、コンテナの起動時間がそのままユーザー体験に影響します。最小の HTTP サーバーを「プロセスを起動してから最初の 200 応答が返るまで」で 25 回ずつ測ったところ、中央値は Node.js 22.22 で 121ms、Bun 1.4.2 で 18ms でした。@anthropic-ai/sdk を読み込む構成では 207ms と 54ms です(Linux x86_64 のサンドボックス上で、プロセス起動と curl の時間を含む値です)。絶対値は環境で変わりますので、ご覧いただきたいのは比率のほうです。ご自身の構成で同じ測り方を一度試していただければと思います。
ふたつ目は TypeScript のネイティブ実行 です。Bun は tsx も ts-node も swc も不要で、.ts ファイルを直接実行できます。これは開発時の起動速度だけでなく、Docker イメージのサイズ削減(ビルドステップの削除)、CI 時間の短縮、トランスパイルバグの根絶という三方良しの効果があります。
みっつ目は 組み込みプリミティブの充実 です。SQLite、WebSocket、テストランナー、パッケージマネージャ、HTTP サーバーが標準同梱されています。Node.js + Express + sqlite3 + ws + jest という構成が、Bun だけで完結します。これは依存関係の管理コストを確実に減らします。
逆に、Bun を選ぶべきでない場面もあります。AWS Lambda の Node.js ランタイムを直接使う、CommonJS 専用の古い社内ライブラリに強く依存している、ネイティブアドオン(node-canvas など)を多用している、といったケースです。これらの場合は Node.js 22 LTS のほうが安全です。
アーキテクチャ:依存最小のClaude APIプロキシ
設計の前提を共有しておきます。今回作るのは「クライアントから受け取ったプロンプトを Claude API に転送し、ストリーミングレスポンスをそのまま返す」シンプルなプロキシです。ただし以下の本番要件を満たします。
リクエストごとのトークン使用量とコストを SQLite に記録する
同一ユーザーからの並列リクエストを制限する(簡易レートリミット)
Anthropic 側のエラーをそのまま返さず、内部で分類してログに残す
ストリーミング中の中断(クライアント切断)を検知して Claude への課金リクエストを止める
外部依存は @anthropic-ai/sdk だけです。HTTP サーバー、SQLite、WebSocket は全て Bun 組み込みを使います。
クライアント → Bun.serve (HTTP/WebSocket) → AnthropicProxy
↓
Bun:sqlite (使用量・履歴記録)
↓
Anthropic API (ストリーミング)
ファイル構成はこのくらいシンプルです。
.
├── bunfig.toml
├── package.json
├── src
│ ├── server.ts # エントリポイント
│ ├── proxy.ts # Anthropic 呼び出しロジック
│ ├── usage.ts # SQLite 使用量記録
│ ├── ratelimit.ts # in-memory レートリミット
│ └── errors.ts # エラー分類
└── tests
└── proxy.test.ts
ストリーミング対応のHTTPサーバー実装
Bun.serve は Node.js の http.createServer よりずっと書きやすい API を提供します。Claude のストリーミングレスポンスをそのまま転送する例を見てみましょう。
// src/server.ts
import Anthropic from "@anthropic-ai/sdk" ;
import { recordUsage } from "./usage" ;
import { checkRateLimit, releaseRateLimit } from "./ratelimit" ;
import { classifyError } from "./errors" ;
const client = new Anthropic ({ apiKey: process.env. ANTHROPIC_API_KEY ! });
const server = Bun. serve ({
port: 8787 ,
// Bun は async fetch をネイティブサポート
async fetch ( req : Request , server ) {
const url = new URL (req.url);
if (url.pathname !== "/api/chat" ) {
return new Response ( "Not Found" , { status: 404 });
}
const userId = req.headers. get ( "x-user-id" ) ?? "anonymous" ;
// 並列リクエスト制限(同一ユーザーは2並列まで)
const allowed = await checkRateLimit (userId);
if ( ! allowed) {
return new Response ( "Too Many Requests" , { status: 429 });
}
const { messages } = await req. json ();
// ReadableStream を使って Anthropic のストリームを HTTP レスポンスに変換
const stream = new ReadableStream ({
async start ( controller ) {
try {
const response = await client.messages. stream ({
model: "claude-opus-4-6" ,
max_tokens: 1024 ,
messages,
});
for await ( const event of response) {
if (event.type === "content_block_delta" ) {
const text = (event.delta as { text : string }).text;
controller. enqueue ( new TextEncoder (). encode (text));
}
}
// 使用量を SQLite に保存
const final = await response. finalMessage ();
recordUsage ({
userId,
inputTokens: final.usage.input_tokens,
outputTokens: final.usage.output_tokens,
createdAt: Date. now (),
});
controller. close ();
} catch (err) {
const classified = classifyError (err);
console. error ( "[chat-error]" , classified);
controller. error (classified);
} finally {
// 解放は「ストリームが流れ終わる側」で行う(理由は並列制限の節)
releaseRateLimit (userId);
}
},
});
return new Response (stream, {
headers: {
"Content-Type" : "text/plain; charset=utf-8" ,
"Cache-Control" : "no-cache, no-transform" ,
},
});
},
});
console. log ( `Listening on http://localhost:${ server . port }` );
注目してほしいのは messages.stream の戻り値が直接 for await で反復できる点です。Anthropic SDK は Node.js でも同じ書き方ができます。レスポンス全体の時間は上流(Anthropic 側)の生成時間がほとんどを占めますので、ここでランタイムの差を期待しすぎないほうが堅実です。Bun で変わるのは、起動の速さと、同時に抱えられる接続の軽さのほうです。
期待される動作はこうです。クライアントが POST /api/chat でメッセージ配列を送ると、レスポンスが text/plain チャンクで順次到着します。ターミナルから動作確認するなら以下のようになります。
curl -N -X POST http://localhost:8787/api/chat \
-H "Content-Type: application/json" \
-H "x-user-id: dev1" \
-d '{"messages":[{"role":"user","content":"Hello in 3 words"}]}'
# → Hello, hello, hello!
組み込み SQLite で使用量を記録する
bun:sqlite を使うと、Node.js + better-sqlite3 と同等の性能を依存ゼロで得られます。Anthropic への課金額を正確に把握するには、リクエストごとに input_tokens と output_tokens を保存しておくのが確実です。
// src/usage.ts
import { Database } from "bun:sqlite" ;
const db = new Database ( "usage.db" , { create: true });
db. exec ( `
CREATE TABLE IF NOT EXISTS usage_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
input_tokens INTEGER NOT NULL,
output_tokens INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_usage_user ON usage_log(user_id, created_at);
` );
const insertStmt = db. prepare ( `
INSERT INTO usage_log (user_id, input_tokens, output_tokens, created_at)
VALUES ($userId, $inputTokens, $outputTokens, $createdAt)
` );
export interface UsageRecord {
userId : string ;
inputTokens : number ;
outputTokens : number ;
createdAt : number ;
}
export function recordUsage ( rec : UsageRecord ) {
insertStmt. run ({
$userId: rec.userId,
$inputTokens: rec.inputTokens,
$outputTokens: rec.outputTokens,
$createdAt: rec.createdAt,
});
}
export function getDailyCost ( userId : string , date : Date ) : number {
const start = new Date (date);
start. setHours ( 0 , 0 , 0 , 0 );
const end = new Date (start);
end. setDate (end. getDate () + 1 );
const row = db
. prepare (
`SELECT
COALESCE(SUM(input_tokens), 0) AS in_tok,
COALESCE(SUM(output_tokens), 0) AS out_tok
FROM usage_log
WHERE user_id = $userId AND created_at >= $start AND created_at < $end`
)
. get ({
$userId: userId,
$start: start. getTime (),
$end: end. getTime (),
}) as { in_tok : number ; out_tok : number };
// Claude Opus 4.6 の単価(USD / 100 万トークン。入力 $5・出力 $25。公式の料金表で確認した値)
// 料金は改定されるので、本番では定数ではなく設定ファイルに外出ししてください
const inputCost = (row.in_tok / 1_000_000 ) * 5 ;
const outputCost = (row.out_tok / 1_000_000 ) * 25 ;
return inputCost + outputCost;
}
bun:sqlite の prepared statement は同期的に動きます。1 行ずつ自動コミットで 20,000 件を書き込んだところ、既定のジャーナルでは毎秒およそ 970 件、WAL モードでは毎秒およそ 5,600 件でした(先ほどと同じサンドボックスでの値です)。同じ INSERT でも、WAL かどうかで 5 倍以上の差が出たことになります。Claude API の応答時間が数秒であることを思えば、どちらでも当面は足りますが、余裕を取っておく意味は十分にあります。
本番では WAL モードを有効にしておくことを強く推奨します。db.exec("PRAGMA journal_mode = WAL;") を初期化時に追加するだけで、読み取り中の書き込み詰まりも避けやすくなります。
並列リクエスト制御:簡易レートリミット
エッジで動かす場合、Redis を立てるほどでもない小規模サービスは多いです。Bun の組み込み機能だけで十分な簡易レートリミットを書いておきます。
// src/ratelimit.ts
const inflight = new Map < string , number >();
const MAX_PARALLEL = 2 ;
export async function checkRateLimit ( userId : string ) : Promise < boolean > {
const current = inflight. get (userId) ?? 0 ;
if (current >= MAX_PARALLEL ) {
return false ;
}
inflight. set (userId, current + 1 );
return true ;
}
export function releaseRateLimit ( userId : string ) {
const current = inflight. get (userId) ?? 0 ;
if (current <= 1 ) {
inflight. delete (userId);
} else {
inflight. set (userId, current - 1 );
}
}
このコードはプロセス単位でしか管理できないので、複数プロセスで動かす場合は SQLite に短期テーブルを作るか、Bun 組み込みの Redis クライアント(Bun.redis)で Redis に切り替えます。が、月間 100 万リクエスト程度の個人サービスなら、Bun を 1 プロセスで動かして十分です。
解放の置き場所には、落とし穴があります。この節を最初に書いたとき、私は「fetch ハンドラ全体を try...finally で包み、finally で releaseRateLimit を呼べば確実です」と説明していました。そのまま組み込んでいたら、制限は一度も働かなかったはずです。
理由は単純で、fetch ハンドラは Response を作った時点で戻ります。本文のストリームは、戻ったあとに流れ続けるのです。finally が走るのはストリームが流れ始める前——つまり、枠はほぼ即座に返却されてしまいます。
実際に確かめました。上流を「200ms ごとに 5 チャンクを流す模擬」に差し替え、同じユーザーから 4 本を同時に投げます(上限は 2 です)。
// rl.ts — 並列制限の解放位置を比べる試験(上流は模擬)
const MAX = 2 ;
const inflight = new Map < string , number >();
const acquire = ( u : string ) => {
const c = inflight. get (u) ?? 0 ;
if (c >= MAX ) return false ;
inflight. set (u, c + 1 );
return true ;
};
const release = ( u : string ) => {
const c = inflight. get (u) ?? 0 ;
c <= 1 ? inflight. delete (u) : inflight. set (u, c - 1 );
};
async function* upstream () {
for ( let i = 0 ; i < 5 ; i ++ ) { await Bun. sleep ( 200 ); yield "x" ; }
}
function handler ( mode : "fetch-finally" | "stream-finally" ) {
return async () => {
if ( ! acquire ( "u" )) return new Response ( "429" , { status: 429 });
const make = () =>
new Response ( new ReadableStream ({
async start ( c ) {
try {
for await ( const t of upstream ()) c. enqueue ( new TextEncoder (). encode (t));
c. close ();
} finally {
if (mode === "stream-finally" ) release ( "u" );
}
},
}));
if (mode === "fetch-finally" ) { try { return make (); } finally { release ( "u" ); } }
return make ();
};
}
for ( const mode of [ "fetch-finally" , "stream-finally" ] as const ) {
const server = Bun. serve ({ port: 0 , fetch: handler (mode) });
const codes = await Promise . all ([ 1 , 2 , 3 , 4 ]. map ( async () => {
const r = await fetch ( `http://localhost:${ server . port }/` );
await r. text ();
return r.status;
}));
console. log (mode, codes. join ( " " ));
server. stop ( true );
}
結果は次のとおりでした(Bun 1.4.2)。
fetch ハンドラ側で解放した版は、上限 2 のはずが 4 本とも通り、上流には 4 本が同時にぶら下がっていました。ストリーム側で解放した版は、2 本が 429 で止まり、上流の同時本数も 2 本に収まりました。先ほどの server.ts が ReadableStream の start の中で解放しているのは、この結果に基づく書き方です。
数えるのは、ハンドラが戻るまでではなく、最後のバイトが流れ終わるまでです。
エラー分類:本番で意味のある内訳を取る
Anthropic SDK のエラーをそのまま console.error するだけでは、運用時に何が起きているか把握できません。ステータスコードと内容で分類しておくと、ダッシュボードで「過負荷」「契約超過」「クライアント側ミス」が一目で判別できます。
// src/errors.ts
import Anthropic from "@anthropic-ai/sdk" ;
export type ErrorCategory =
| "client_error" // 400/422 — リクエスト側のミス
| "auth_error" // 401/403 — 認証問題
| "rate_limit" // 429 — Anthropic 側レート制限
| "overloaded" // 529 — Anthropic 側過負荷
| "server_error" // 500/503
| "network" // タイムアウト・切断
| "client_abort" // クライアント側の切断(こちらが止めたもの)
| "unknown" ;
export interface ClassifiedError {
category : ErrorCategory ;
status ?: number ;
message : string ;
shouldRetry : boolean ;
}
export function classifyError ( err : unknown ) : ClassifiedError {
// APIUserAbortError は APIError の子クラスで status を持たない。
// 先に判定しないと下の client_error に入ってしまう
if (err instanceof Anthropic . APIUserAbortError ) {
return { category: "client_abort" , message: err.message, shouldRetry: false };
}
if (err instanceof Anthropic . APIError ) {
const status = err.status;
if (status === 429 ) {
return {
category: "rate_limit" ,
status,
message: err.message,
shouldRetry: true ,
};
}
if (status === 529 ) {
return {
category: "overloaded" ,
status,
message: err.message,
shouldRetry: true ,
};
}
if (status === 401 || status === 403 ) {
return {
category: "auth_error" ,
status,
message: err.message,
shouldRetry: false ,
};
}
if (status !== undefined && status >= 500 ) {
return {
category: "server_error" ,
status,
message: err.message,
shouldRetry: true ,
};
}
return {
category: "client_error" ,
status,
message: err.message,
shouldRetry: false ,
};
}
if (err instanceof Anthropic . APIConnectionError ) {
return {
category: "network" ,
message: err.message,
shouldRetry: true ,
};
}
return {
category: "unknown" ,
message: err instanceof Error ? err.message : String (err),
shouldRetry: false ,
};
}
エラー分類は記録しておくと SLO 設計にも使えます。たとえば「auth_error が発生したら即座に Slack 通知、rate_limit は 5 分以内に 10 件超えたら通知」のような運用ルールが書きやすくなります。
client_abort を独立させた理由も書いておきます。模擬の Anthropic サーバーを相手にクライアントを途中で切ってみたところ、SDK が投げたのは APIUserAbortError でした。status を持たないため、分類を足す前の関数では client_error(リクエスト側のミス)に入っていました。切断のたびにダッシュボードの「リクエスト側のミス」が増え、本物の 400 番台が埋もれてしまうのです。
クライアント切断の検知:無駄な課金を止める
切断を無視して上流を走らせ続ければ、その分の出力トークンが料金に載ります。Bun の Request.signal はクライアント切断時に発火するので、これを Anthropic SDK の AbortController に橋渡しします。
// server.ts の fetch ハンドラ内、ストリーム生成前に追加
const ac = new AbortController ();
req.signal. addEventListener ( "abort" , () => {
console. log ( "[client-disconnect]" , { userId });
ac. abort ();
});
const response = await client.messages. stream (
{
model: "claude-opus-4-6" ,
max_tokens: 1024 ,
messages,
},
{ signal: ac.signal }
);
実際に動かして確かめました。模擬の Anthropic サーバー(150ms ごとに 8 チャンクを流します)を相手に、2 チャンク受け取った時点でクライアントを切ると、req.signal の abort、ストリームの cancel、SDK の APIUserAbortError の順に発火し、模擬サーバー側のストリームも閉じられました。cancel のあとで controller.error() を呼んでも例外にはなりません。最後まで読み切った場合は、finalMessage() の使用量(入力 10・出力 8 の模擬値)が記録されました。
Bun ならではの落とし穴 5 選
ここまでで基本の実装は終わりですが、Bun を本番投入する前に必ず把握しておくべき落とし穴があります。私が実際に踏んだものを優先度順に並べます。
ひとつ目は Node 互換性の境界 です。Bun は Node.js の API 互換性が高いのですが、まだ完全ではありません。特に crypto.createHash の一部メソッド、net モジュールの低レベル操作、worker_threads の挙動が Node とずれることがあります。Anthropic SDK 自体は問題なく動きますが、周辺ライブラリ(特に古い HTTP クライアント)で稀に問題が出ます。あわせて覚えておきたいのが、Bun は依存パッケージのインストールスクリプトを既定では実行しないことです。ネイティブアドオンを含むパッケージを入れたときは bun pm untrusted で保留されたものを確認し、必要なものだけ bun pm trust <パッケージ名> で許可します。--all で一括許可するより、一つずつ見たほうが安全です。
ふたつ目は テストランナーの差異 です。bun test は Jest 互換 API を目指していますが、describe.skip.each や一部のスナップショット周りで挙動が異なります。既存の Jest テストをそのまま動かすと、落ちるテストが出ることがあります。私のおすすめは、新規プロジェクトは最初から bun test で書く、移行プロジェクトは既存の Jest を Node で動かしたまま、新しいテストだけ bun test で足していく、です。
// tests/proxy.test.ts — bun test ネイティブ
import { test, expect } from "bun:test" ;
import { classifyError } from "../src/errors" ;
import Anthropic from "@anthropic-ai/sdk" ;
test ( "classifies 429 as rate_limit with retry" , () => {
const err = new Anthropic. APIError ( 429 , undefined , "rate limited" , undefined );
const result = classifyError (err);
expect (result.category). toBe ( "rate_limit" );
expect (result.shouldRetry). toBe ( true );
});
test ( "classifies 401 as auth_error without retry" , () => {
const err = new Anthropic. APIError ( 401 , undefined , "unauthorized" , undefined );
const result = classifyError (err);
expect (result.category). toBe ( "auth_error" );
expect (result.shouldRetry). toBe ( false );
});
みっつ目は Linux 配布バイナリのサイズ です。bun build --compile で単一バイナリを作れますが、ベースランタイムが含まれます。最小の HTTP サーバー 1 本でも、Linux x86_64 で 78MB になりました。Lambda の zip 直接アップロード上限(50MB)には収まりません。コンテナイメージで配るのが現実的です。
# Dockerfile
# 本番では、動作確認したバージョンのタグに固定してください
FROM oven/bun:1-alpine AS base
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
EXPOSE 8787
CMD [ "bun" , "run" , "src/server.ts" ]
よっつ目は メモリ管理の挙動の違い です。Node.js は --max-old-space-size で詳細制御できますが、Bun はその概念が異なります。長時間動かすサーバーでメモリリークを疑うときは、bun --smol の有無でメモリの推移を比べると切り分けやすくなります。
いつつ目は PM2 / forever との相性 です。プロセスマネージャ経由で起動すると bun がうまく検出されないことがあります。Docker + restart=always か、systemd の Type=simple で運用するのが安全です。
移行戦略:Node.js から Bun への段階的移行
既に Node.js で本番稼働しているClaude APIサーバーがある場合、いきなり全面移行するのは怖いはずです。私が複数のプロジェクトで採用した段階的移行手順を共有します。
最初のステップは、ローカル開発だけ Bun に切り替える ことです。package.json の scripts.dev を bun src/server.ts --watch に変えるだけで、開発時の起動速度とホットリロードの恩恵を受けられます。本番は Node.js のままでも、開発体験が改善するだけで生産性が上がります。
次のステップは、CI のテスト実行を Bun にする ことです。bun test は起動が軽いため、テストが多いプロジェクトほど差が出やすくなります。切り替えの前後で CI の所要時間を比べてください。テストが落ちた場合は、落ちたものだけ Node 側の Jest に戻して原因を切り分けます。
最後のステップが 本番ランタイムの切り替え です。これはステージング環境で 1〜2 週間動かして、メモリ・CPU・レイテンシを Node.js と並行比較してから本番に出します。比較用のメトリクスは Datadog でも Cloudflare Analytics でもよいので、必ず数値で判断してください。「速くなったような気がする」では本番投入の根拠としては弱いです。
比べる指標は、p95 / p99 レイテンシ、アイドル時と負荷時のメモリ、コールドスタート、エラー率の四つに絞ると判断しやすくなります。数値が手元に出るまでは、本番のランタイムを変えません。 私はこの線引きで進めています。
切り替えで安くなるのは、どんなときか
数字で語ったほうが意思決定しやすいので、まず先ほどの最小サーバーで測れた範囲をお伝えします。最初の応答を返した時点の常駐メモリは、Node.js 22.22 で約 54MB、Bun 1.4.2 で約 15MB でした。Claude API プロキシでは SDK と SQLite が乗りますので、差は縮みます。それでも、同じ台数で抱えられるストリームの本数が変わる余地はあります。
ただし、ここから「Bun は安い」と一般化するのは危険です。費用が下がるとすれば、それは Bun が本質的に安いからではなく、1 インスタンスで十分になったときです。Claude プロキシの負荷の中心は CPU ではなく、同時に開いているストリームの本数と、その一本あたりのメモリです。見積もりの式は次のとおりです。
すでに 1 台で余裕を持って動いているサービスでは、移行による直接的なコスト削減は小さいかもしれません。それよりも、TypeScript ビルドの省略・CI の短縮・依存関係の整理という間接的な改善のほうが大きな価値になりやすいです。
「安くなった」と書く前に、必要台数が一台減ったかどうかを見ます。 数字の出どころが自分の負荷試験でなければ、判断の根拠としては弱いままです。
Node と Bun の差だと思い込んでいたもの、本当に差があったもの
Anthropic SDK は、Node.js でも Bun でもグローバルの fetch を使います。Node.js の fetch は undici ベース、Bun は組み込みの実装です。通常のリクエスト処理では同じに見えますが、差があると思い込んでいた点を、書く前に確かめました。
ひとつ目は ヘッダ名の大文字小文字 です。以前の私はここに「headers.get("Anthropic-Request-Id") と先頭大文字で読むと、Bun では黙って null が返る」と書いていました。実際に試すと、headers.get("Request-Id") も headers.get("request-id") も同じ値が返りました。Headers は仕様上、名前の大文字小文字を区別しないのです。差が出るのは、[...headers.keys()] で列挙したときに、キーが小文字で並ぶ点だけでした。ヘッダを列挙して比較・保存するコードだけ、小文字前提で書いてください。
ふたつ目は SSE のチャンク境界 です。Anthropic SDK は内部で SSE をパースしてくれるので通常は気になりませんが、自前でストリーミングを処理する場合、Response.body.getReader() から返るチャンクの区切りは実装ごとに異なりえます。\n\n で安易にスプリットせず、イベント境界を見つけるまでバッファするロジックにしてください。
アイドル接続の再利用にも差があるかもしれませんが、私はまだ測れていません。測れていないものは、差があるとも、ないとも書かないことにしています。
差があると書く前に、一行で確かめます。 Node では通っていたテストが Bun で原因不明にコケる、という時間泥棒は、差を理解していれば避けられます。理解したつもりの差を、確かめないまま増やさないことも同じくらい大切です。
重い監視スタックを使わない本番オブザーバビリティ
個人開発や少人数チームの場合、Datadog や New Relic の本格導入は過剰なことが多いです。Bun の組み込み機能と構造化ログだけで、必要十分な観測性は確保できます。
// src/observability.ts
type LogLevel = "info" | "warn" | "error" ;
interface LogFields {
msg : string ;
userId ?: string ;
reqId ?: string ;
tokenUsage ?: { input : number ; output : number };
latencyMs ?: number ;
errorCategory ?: string ;
[ key : string ] : unknown ;
}
export function log ( level : LogLevel , fields : LogFields ) {
const entry = {
ts: new Date (). toISOString (),
level,
... fields,
};
process.stdout. write ( JSON . stringify (entry) + " \n " );
}
export function startSpan ( name : string ) {
const startedAt = performance. now ();
return {
end ( extra : Partial < LogFields > = {}) {
const latencyMs = Math. round (performance. now () - startedAt);
log ( "info" , { msg: `span_end:${ name }` , latencyMs, ... extra });
},
};
}
fetch ハンドラの最初に const span = startSpan("chat_request"); を仕込み、finally で span.end({ userId, tokenUsage }) を呼ぶだけで、リクエストごとのレイテンシ・トークン使用量・トレース ID が JSON ログとして残ります。これだけで Cloud Run / Fly / Datadog Agent のいずれの環境でも、ログ検索で十分な観測ができます。後で OpenTelemetry に切り替える場合も、log と startSpan の中身を差し替えるだけで呼び出し側は変えずに済みます。
最初から重い計装を入れるのは、それ自体がコストです。Bun 上ならむしろ「最小限の構造化ログ」から始めて、必要が見えてから重い装備を足すほうが運用は健全だと思います。
ホットリロードと開発体験
Bun のもう一つの隠れた利点は開発時のフィードバックループです。bun --hot src/server.ts はプロセスを再起動せずにモジュールを再ロードします。インメモリのレートリミットや SQLite 接続を保持したままコード変更が反映されるので、Node + nodemon の「保存 → 2〜3 秒のフルリスタート → 状態消失」というサイクルから解放されます。
Claude API サービスでは特に効きます。ブラウザで開いたチャットセッションを保持したままプロンプトロジックを編集でき、次のリクエストから新しいコードが動きます。毎回の再起動待ちが消えるだけで、試行の回数は自然に増えます。
// package.json
{
"name" : "claude-bun-proxy" ,
"type" : "module" ,
"scripts" : {
"dev" : "bun --hot src/server.ts" ,
"test" : "bun test" ,
"build" : "bun build src/server.ts --target=bun --outdir dist"
},
"dependencies" : {
"@anthropic-ai/sdk" : "^0.133.0"
}
}
ひとつ補足として、bun build は単一ファイルを生成しますが、ランタイムは含みません。出力は Bun 用です。Node にも配布したい場合は bun build --compile で 60〜80MB のバイナリを作るか、コンテナでまとめて配布するのが現実的です。
ワーカースレッドが本当に効く場面
ほとんどのClaude APIプロキシではワーカースレッドは不要です。レイテンシの大半はアップストリーム(Anthropic 側)にあり、ローカル CPU が詰まることは稀だからです。ただし、以下の 2 つのシナリオでは効果があります。
ひとつ目は ストリーミング出力の後処理 。応答全体に対して正規表現の検閲・要約埋め込みの計算・構造化抽出をかける場合、その処理はリクエストスレッドから外したほうがレイテンシが安定します。Bun は標準の worker_threads API をサポートしているので、Node から知っているパターンがそのまま使えます。
ふたつ目は 大量ドキュメントを連結した長いプロンプトの組み立て 。「100 件の顧客メールを要約してください」のような機能では、JSON パースと文字列結合がストリーミングループと CPU を奪い合います。これをワーカーに逃すと、イベントループが詰まらずチャンクの送出も安定します。
注意点として、bun:sqlite の接続をワーカー間で共有することはできません。各ワーカーで個別にデータベースをオープンし、SQLite 自身の並行制御(WAL モードが効きます)に任せるのが正解です。
エッジ・ホスティングの選び方
Bun サービスをどこに置くか迷ったときの、2026 年時点でのClaude API 視点での比較を共有します。
Cloud Run(GCP) : 一番素直。ゼロスケール対応で、リクエスト課金なので Anthropic の課金モデルとも噛み合います
Fly.io : 「フレンドリーなダッシュボード付き VM」が一番近いイメージ。常時起動でレートリミット状態を保ちたい個人プロジェクトと相性が良いです
AWS App Runner : 動きますがコールドスタートとイメージキャッシュ周りが粗め。すでに AWS にいるなら検討価値あり
Cloudflare Workers : 純粋な Bun サーバーには合いません。Workers を使うなら Cloudflare のランタイムを使うべきで、Bun.serve を Workers 内で動かそうとはしないこと
VM + systemd(自前運用) : 個人プロジェクトでコストを完全に予測したいときの私の好み
選択は「Bun に何が一番合うか」より「すでに何を使っているか」で決めるのが現実的です。Cloud Run を使っているなら Cloud Run のままで十分です。
本番運用前の最終チェックリスト
最後に、Bun 製のClaude APIサーバーを本番投入する前に必ず確認すべき項目をまとめます。
WAL モードを有効化した SQLite で書き込み詰まりが起きないか負荷試験する
Request.signal がクライアント切断時に正しく発火するか curl で確認する(curl -N を Ctrl+C で中断してログを確認)
bun pm untrusted で保留されたインストールスクリプトを確認し、必要なものだけ許可済みである
Anthropic API キーが環境変数経由で渡され、コンテナイメージに焼き込まれていない
ログ出力が JSON 形式で構造化されており、Cloud Run / Cloudflare Logs / Datadog で検索可能
bun --smol 抜きで 24 時間動かしてメモリリークがないか確認する
ステージング環境で Node.js 版と並行稼働させ、p95 / p99 レイテンシ・エラー率・コストを 1 週間以上比較した
Anthropic 側のリトライ可能エラー(rate_limit / overloaded)を指数バックオフで自動再試行する実装が入っている
模擬の上流を相手に同じユーザーから上限+2 本を同時に投げ、超過分が 429 になる
途中でクライアントを切ったとき、ログに client_abort が残り、client_error が増えない
この記事の内容を Claude Code と組み合わせて深掘りするなら、Claude Code でのテスト駆動開発ワークフロー と合わせて読むと、Bun 移行時のテスト戦略が具体化します。Claude API のストリーミング全般を強化したい場合は Claude API ストリーミング切断時の安全な復旧設計 も参考になるはずです。
次にすべきこと
まずは手元で動いている Node.js 製のClaude APIプロキシを、ローカル環境だけ bun src/server.ts に切り替えてみてください。多くのプロジェクトでは設定ファイル一行の変更で動きます。そこから 1 日使ってみて、開発体験が好転すると感じたら、CI のテスト実行 → ステージング環境という順で本番に近づけていきます。
Bun への移行は「最終的に全て置き換える」ことを目的にする必要はありません。開発時だけ・テストだけ・特定のサービスだけでも、コードの依存関係が減り、コンテナサイズが小さくなり、起動が速くなる効果は確実に得られます。「速いから」ではなく「シンプルになるから」という観点で導入を検討するのが、長く付き合うための健全な姿勢ではないかと思います。