コンテンツへスキップ
·9分で読めます

Bun本番運用:うまくいくこと、いかないこと、驚いたこと

ランタイム、パッケージマネージャー、バンドラー、テストランナーとしてのBun。実ベンチマーク、Node.js互換性ギャップ、移行パターン、プロダクションでBunを使っている場面。

シェア:X / TwitterLinkedIn

数年おきにJavaScriptエコシステムに新しいランタイムが登場し、議論は予測可能な軌道をたどる。ハイプ。ベンチマーク。「Xは死んだ。」現実チェック。新しいツールが本当に輝くユースケースへの収束。

Bunは今まさにその軌道の途中にいる。そして、ほとんどの挑戦者と違い、定着しつつある。「速い」からではなく(実際に速いことが多いが)、本当に異なる問題を解決しているからだ:JavaScriptツールチェーンには可動部品が多すぎるが、Bunはそれを1つにまとめる。

私はBunを1年以上、さまざまな場面で使ってきた。一部は本番環境で。一部は、まさか置き換えることはないと思っていたツールの代替として。この記事は、何がうまくいくか、何がだめか、そしてギャップがまだ重要な場面についての正直な報告だ。

Bunの正体#

まず払拭すべき誤解:Bunは「より速いNode.js」ではない。そのフレーミングは過小評価だ。

Bunは1つのバイナリに4つのツールを内蔵している:

  1. JavaScript/TypeScriptランタイム — Node.jsやDenoのようにコードを実行する
  2. パッケージマネージャー — npm、yarn、pnpmの代替
  3. バンドラー — 特定のユースケースでesbuild、webpack、Rollupの代替
  4. テストランナー — ほとんどのテストスイートでJestやVitestの代替

Node.jsとの主要なアーキテクチャの違いはエンジンだ。Node.jsはV8(Chromeのエンジン)を使用する。BunはJavaScriptCore(Safariのエンジン)を使用する。どちらも成熟した本番グレードのエンジンだが、異なるトレードオフがある。JavaScriptCoreは起動時間が速くメモリオーバーヘッドが低い傾向がある。V8は長時間実行される計算でピークスループットが良い傾向がある。実際には、ほとんどのワークロードでこれらの違いは思ったより小さい。

もう1つの主要な差別化要因:BunはZigで書かれている。Zigはシステムプログラミング言語で、Cとほぼ同じレベルにあるが、より良いメモリ安全性の保証を持つ。これがBunがパフォーマンスで攻めることができる理由だ — ZigはCが提供するような低レベルの制御を、Cの落とし穴の密度なしに提供する。

bash
# Bunのバージョンを確認
bun --version
 
# TypeScriptファイルを直接実行 — tsconfigもコンパイルステップも不要
bun run server.ts
 
# パッケージをインストール
bun install
 
# テストを実行
bun test
 
# 本番用にバンドル
bun build ./src/index.ts --outdir ./dist

これは node + npm + esbuild + vitest の仕事を1つのバイナリでこなす。好き嫌いはあるだろうが、複雑さの削減としては魅力的だ。

速度の主張 — 正直なベンチマーク#

率直に言おう:Bunのマーケティングベンチマークはチェリーピックされている。詐欺ではない — チェリーピックだ。Bunが最もよく性能を発揮するシナリオを示しており、それはマーケティング資料として予想通りだ。問題は、人々がそれらのベンチマークから推定して、Bunがすべてにおいて「25倍速い」と主張することで、それは絶対に事実ではない。

Bunが本当に、意味のある速さを持つ場面はここだ:

起動時間#

これはBunの最大の本当の優位性であり、比較にならない。

bash
# 起動時間の計測 — それぞれ100回実行
hyperfine --warmup 5 'node -e "console.log(1)"' 'bun -e "console.log(1)"'
 
# 典型的な結果:
# node:  〜40ms
# bun:   〜6ms

おおよそ6〜7倍の起動時間の差がある。スクリプト、CLIツール、コールドスタートが重要なサーバーレスファンクションでは、これは大きい。一度起動して数週間動き続ける長寿命のサーバープロセスでは、無関係だ。

パッケージインストール#

これもBunが競合を圧倒する分野だ。

bash
# クリーンインストールのベンチマーク — まずnode_modulesとロックファイルを削除
rm -rf node_modules bun.lockb package-lock.json
 
# npmの時間計測
time npm install
# Real: 〜18.4秒(典型的な中規模プロジェクト)
 
# bunの時間計測
time bun install
# Real: 〜2.1秒

8〜9倍の差があり、一貫している。主な理由は:

  1. バイナリロックファイルbun.lockb はJSONではなくバイナリ形式。読み書きが速い。
  2. グローバルキャッシュ — Bunはグローバルモジュールキャッシュを維持し、プロジェクト間の再インストールでダウンロード済みパッケージを共有する。
  3. ZigのI/O — パッケージマネージャー自体がJavaScriptではなくZigで書かれている。ファイルI/O操作がより低レベルに近い。
  4. シンボリックリンク戦略 — Bunはファイルをコピーする代わりに、グローバルキャッシュからハードリンクを使用する。

HTTPサーバースループット#

Bunの組み込みHTTPサーバーは速いが、比較にはコンテキストが必要だ。

bash
# bombardierを使った簡易ベンチマーク
# シンプルな「Hello World」レスポンスのテスト
 
# Bunサーバー
bombardier -c 100 -d 10s http://localhost:3000
# リクエスト/秒: 〜105,000
 
# Node.js(ネイティブhttpモジュール)
bombardier -c 100 -d 10s http://localhost:3001
# リクエスト/秒: 〜48,000
 
# Node.js(Express)
bombardier -c 100 -d 10s http://localhost:3002
# リクエスト/秒: 〜15,000

Bun対素のNode.js:些細なレスポンスでおおよそ2倍。Bun対Express:おおよそ7倍だが、Expressはミドルウェアのオーバーヘッドがあるため不公平だ。実際のロジック — データベースクエリ、認証、実データのJSONシリアライゼーション — を追加した途端、差は劇的に縮まる。

差がほぼないところ#

CPU集約型の計算:

typescript
// fibonacci.ts — これはエンジン依存であり、ランタイム依存ではない
function fib(n: number): number {
  if (n <= 1) return n;
  return fib(n - 1) + fib(n - 2);
}
 
const start = performance.now();
console.log(fib(42));
console.log(`${(performance.now() - start).toFixed(0)}ms`);
bash
bun run fibonacci.ts   # 〜1650ms
node fibonacci.ts      # 〜1580ms

Node.js(V8)が実際にここではわずかに勝つ。V8のJITコンパイラはホットループでより積極的だ。CPU集約的な作業では、エンジンの違いは拮抗している — 時にV8が勝ち、時にJSCが勝ち、差はノイズの範囲内だ。

自分でベンチマークを実行する方法#

私のものを含め、誰のベンチマークも信用するな。あなたの具体的なワークロードにとって重要なことを計測する方法:

bash
# 適切なベンチマーク用にhyperfineをインストール
brew install hyperfine  # macOS
# または: cargo install hyperfine
 
# 実際のアプリの起動+実行をベンチマーク
hyperfine --warmup 3 \
  'node dist/server.js' \
  'bun src/server.ts' \
  --prepare 'sleep 0.1'
 
# HTTPサーバーにはbombardierまたはwrkを使用
# 重要:「Hello World」ではなく現実的なペイロードでテスト
bombardier -c 50 -d 30s -l http://localhost:3000/api/users
 
# メモリ比較
/usr/bin/time -v node server.js  # Linux
/usr/bin/time -l bun server.ts   # macOS

経験則:ボトルネックがI/O(ファイルシステム、ネットワーク、データベース)なら、Bunの優位性は控えめ。ボトルネックが起動時間やツールチェーン速度なら、Bunの大勝利。ボトルネックが生の計算なら、五分五分だ。

パッケージマネージャーとしてのBun#

ここは私が完全に切り替えた領域だ。本番環境でNode.jsを実行しているプロジェクトでも、ローカル開発とCIでは bun install を使っている。単純に速く、互換性も優秀だ。

基本操作#

bash
# package.jsonからすべての依存関係をインストール
bun install
 
# 依存関係を追加
bun add express
 
# 開発依存関係を追加
bun add -d vitest
 
# 依存関係を削除
bun remove express
 
# 依存関係を更新
bun update express
 
# 特定バージョンをインストール
bun add express@4.18.2

npmやyarnを使ったことがあれば、完全に馴染みのある操作だ。フラグが少し異なるが(--save-dev の代わりに -d)、メンタルモデルは同じだ。

ロックファイルの状況#

Bunは bun.lockb というバイナリロックファイルを使用する。これはその最強の武器であると同時に、最大の摩擦点でもある。

良い点:読み書きが劇的に速い。バイナリ形式のため、Bunはロックファイルをマイクロ秒で解析できる。npmが package-lock.json の解析に費やす数百ミリ秒ではない。

悪い点:diffでレビューできない。チームで誰かが依存関係を更新した場合、PRでロックファイルのdiffを見て何が変わったか確認できない。これは速度推進派が認めたがるより重要だ。

bash
# ロックファイルを人間が読める形式にダンプ
bun bun.lockb > lockfile-dump.txt
 
# または組み込みのテキスト出力を使用
bun install --yarn
# bun.lockbと並行してyarn.lockを生成する

私のアプローチ:bun.lockb をリポジトリにコミットし、読めるフォールバックとして yarn.lock または package-lock.json も生成する。ベルトとサスペンダーの両方だ。

ワークスペースサポート#

Bunはnpm/yarnスタイルのワークスペースをサポートしている:

json
{
  "name": "my-monorepo",
  "workspaces": [
    "packages/*",
    "apps/*"
  ]
}
bash
# すべてのワークスペースの依存関係をインストール
bun install
 
# 特定のワークスペースでスクリプトを実行
bun run --filter packages/shared build
 
# 特定のワークスペースに依存関係を追加
bun add react --filter apps/web

ワークスペースサポートは堅牢で、大幅に改善されている。pnpmと比較した主なギャップは、Bunのワークスペース依存関係解決がそれほど厳格でないこと — pnpmの厳格さはモノレポの機能だ。ファントム依存関係を検出するからだ。

既存プロジェクトとの互換性#

ほぼすべての既存Node.jsプロジェクトに bun install をそのまま導入できる。package.json を読み、レジストリ設定用の .npmrc を尊重し、peerDependencies を正しく処理する。移行は通常:

bash
# ステップ1:既存のロックファイルとnode_modulesを削除
rm -rf node_modules package-lock.json yarn.lock pnpm-lock.yaml
 
# ステップ2:Bunでインストール
bun install
 
# ステップ3:アプリがまだ動作するか確認
bun run dev
# または: node dist/server.js(Bunパッケージマネージャー、Nodeランタイム)

十数個のプロジェクトでこれを行い、パッケージマネージャー自体での問題はゼロだった。唯一の落とし穴は、CIパイプラインが package-lock.json を明示的に探している場合 — bun.lockb を処理するように更新が必要だ。

Node.js互換性#

このセクションは最も慎重に書く必要がある。状況が毎月変わるからだ。2026年初頭時点での正直な状況を紹介する。

動作するもの#

npmパッケージの大多数が変更なしで動作する。BunはほとんどのNode.js組み込みモジュールを実装している:

typescript
// これらはすべてBunで期待通りに動作する
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
import { Buffer } from "node:buffer";
import { EventEmitter } from "node:events";
import { Readable, Writable } from "node:stream";
import http from "node:http";
import https from "node:https";
import { URL, URLSearchParams } from "node:url";
import os from "node:os";
import child_process from "node:child_process";

CommonJSとESMの両方が動作する。require()import が共存できる。TypeScriptはコンパイルステップなしで実行される — Bunはパース時に型を除去する。

動作するフレームワーク:

  • Express — ミドルウェアエコシステムを含めて動作
  • Fastify — 動作
  • Hono — 動作(Bunとの相性が特に良い)
  • Next.js — 注意事項あり(後述)
  • Prisma — 動作
  • Drizzle ORM — 動作
  • Socket.io — 動作

動作しない(または問題がある)もの#

ギャップはいくつかのカテゴリに分かれる傾向がある:

ネイティブアドオン(node-gyp):パッケージがnode-gypでコンパイルされたC++アドオンを使用している場合、Bunで動作しない可能性がある。Bunは独自のFFIシステムを持ち多くのネイティブモジュールをサポートしているが、カバレッジは100%ではない。例えば、bcrypt(ネイティブ版)には問題があった — 代わりに bcryptjs を使用する。

bash
# パッケージがネイティブアドオンを使用しているか確認
ls node_modules/your-package/binding.gyp  # これが存在すればネイティブ

特定のNode.js内部API:一部のパッケージは process.binding() のようなNode.js内部やV8固有のAPIに手を伸ばす。BunはJavaScriptCoreで動作するため、これらは動作しない。

typescript
// これはBunで動作しない — V8固有
const v8 = require("v8");
v8.serialize({ data: "test" });
 
// これは動作する — Bunの同等機能やクロスランタイムのアプローチを使用
const encoded = new TextEncoder().encode(JSON.stringify({ data: "test" }));

ワーカースレッド:BunはWeb Workersと node:worker_threads をサポートしているが、エッジケースがある。特に SharedArrayBufferAtomics に関する高度な使用パターンは動作が異なる場合がある。

vmモジュールnode:vm は部分的にサポートされている。コードや依存関係が vm.createContext() を多用している場合(一部のテンプレートエンジンが該当)、徹底的にテストすること。

互換性トラッカー#

Bunは公式の互換性トラッカーを維持している。プロジェクトでBunにコミットする前にチェックすること:

bash
# プロジェクトでBunの組み込み互換性チェックを実行
bun --bun node_modules/.bin/your-tool
 
# --bunフラグはnode_modulesスクリプトにもBunのランタイムを強制する

私の推奨:互換性を仮定しないこと。決定する前にBunでテストスイートを実行する。5分で済み、何時間ものデバッグを節約できる。

bash
# クイック互換性チェック — Bunで完全なテストスイートを実行
bun test  # bunテストランナーを使用する場合
# または
bun run vitest  # vitestを使用する場合

Bunの組み込みAPI#

ここがBunの面白いところだ。Node.jsのAPIを再実装するだけでなく、よりシンプルで高速に設計された独自のAPIを提供している。

Bun.serve() — 組み込みHTTPサーバー#

最もよく使うAPIだ。クリーンで、速く、WebSocketサポートが組み込まれている。

typescript
const server = Bun.serve({
  port: 3000,
  fetch(req) {
    const url = new URL(req.url);
 
    if (url.pathname === "/") {
      return new Response("Hello from Bun!", {
        headers: { "Content-Type": "text/plain" },
      });
    }
 
    if (url.pathname === "/api/users") {
      const users = [
        { id: 1, name: "Alice" },
        { id: 2, name: "Bob" },
      ];
      return Response.json(users);
    }
 
    return new Response("Not Found", { status: 404 });
  },
});
 
console.log(`サーバー起動中: http://localhost:${server.port}`);

いくつか注目すべき点:

  1. Web標準のRequest/Response — 独自APIなし。fetch ハンドラは標準の Request を受け取り、標準の Response を返す。Cloudflare Workerを書いたことがあれば、まったく同じ感覚だ。
  2. Response.json() — 組み込みのJSONレスポンスヘルパー。
  3. インポート不要Bun.serve はグローバル。require("http") は不要。

ルーティング、JSONボディ解析、エラーハンドリングを含むより現実的な例:

typescript
import { Database } from "bun:sqlite";
 
const db = new Database("app.db");
db.run(`
  CREATE TABLE IF NOT EXISTS todos (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    title TEXT NOT NULL,
    completed INTEGER DEFAULT 0,
    created_at TEXT DEFAULT (datetime('now'))
  )
`);
 
const server = Bun.serve({
  port: process.env.PORT || 3000,
 
  async fetch(req) {
    const url = new URL(req.url);
    const method = req.method;
 
    try {
      // GET /api/todos
      if (url.pathname === "/api/todos" && method === "GET") {
        const todos = db.query("SELECT * FROM todos ORDER BY created_at DESC").all();
        return Response.json(todos);
      }
 
      // POST /api/todos
      if (url.pathname === "/api/todos" && method === "POST") {
        const body = await req.json();
 
        if (!body.title || typeof body.title !== "string") {
          return Response.json({ error: "タイトルは必須です" }, { status: 400 });
        }
 
        const stmt = db.prepare("INSERT INTO todos (title) VALUES (?) RETURNING *");
        const todo = stmt.get(body.title);
        return Response.json(todo, { status: 201 });
      }
 
      // DELETE /api/todos/:id
      const deleteMatch = url.pathname.match(/^\/api\/todos\/(\d+)$/);
      if (deleteMatch && method === "DELETE") {
        const id = parseInt(deleteMatch[1], 10);
        db.run("DELETE FROM todos WHERE id = ?", [id]);
        return new Response(null, { status: 204 });
      }
 
      return Response.json({ error: "見つかりません" }, { status: 404 });
    } catch (error) {
      console.error("リクエストエラー:", error);
      return Response.json({ error: "内部サーバーエラー" }, { status: 500 });
    }
  },
});
 
console.log(`サーバーがポート ${server.port} で起動中`);

SQLiteを使った完全なCRUD APIが約50行。Express不要、ORM不要、ミドルウェアチェーン不要。小さなAPIや社内ツールでは、これが今の私のお気に入りのセットアップだ。

Bun.file() と Bun.write() — ファイルI/O#

BunのファイルAPIは fs.readFile() と比べて爽快なほどシンプルだ:

typescript
// ファイルの読み取り
const file = Bun.file("./config.json");
const text = await file.text();       // 文字列として読み取り
const json = await file.json();       // JSONとして直接パース
const bytes = await file.arrayBuffer(); // ArrayBufferとして読み取り
const stream = file.stream();          // ReadableStreamとして読み取り
 
// ファイルメタデータ
console.log(file.size);  // バイト単位のサイズ
console.log(file.type);  // MIMEタイプ(例:"application/json")
 
// ファイルの書き込み
await Bun.write("./output.txt", "Hello, World!");
await Bun.write("./data.json", JSON.stringify({ key: "value" }));
await Bun.write("./copy.png", Bun.file("./original.png"));
 
// Responseボディをファイルに書き込み
const response = await fetch("https://example.com/data.json");
await Bun.write("./downloaded.json", response);

Bun.file() APIは遅延評価だ — .text().json() などを呼ぶまでファイルは読まれない。つまり、実際にデータが必要になるまでI/Oコストなしに Bun.file() の参照を渡し回せる。

組み込みWebSocketサポート#

Bun.serve() ではWebSocketがファーストクラスだ:

typescript
const server = Bun.serve({
  port: 3000,
 
  fetch(req, server) {
    const url = new URL(req.url);
 
    if (url.pathname === "/ws") {
      const upgraded = server.upgrade(req, {
        data: {
          userId: url.searchParams.get("userId"),
          joinedAt: Date.now(),
        },
      });
 
      if (!upgraded) {
        return new Response("WebSocketアップグレードに失敗", { status: 400 });
      }
      return undefined;
    }
 
    return new Response("WebSocket接続には /ws を使用してください");
  },
 
  websocket: {
    open(ws) {
      console.log(`クライアント接続: ${ws.data.userId}`);
      ws.subscribe("chat");
    },
 
    message(ws, message) {
      // すべてのサブスクライバーにブロードキャスト
      server.publish("chat", `${ws.data.userId}: ${message}`);
    },
 
    close(ws) {
      console.log(`クライアント切断: ${ws.data.userId}`);
      ws.unsubscribe("chat");
    },
  },
});

server.publish()ws.subscribe() パターンは組み込みのpub/subだ。Redis不要、別途WebSocketライブラリ不要。シンプルなリアルタイム機能には、信じられないほど便利だ。

bun:sqlite による組み込みSQLite#

これが一番驚いた。BunはSQLiteをランタイムに直接組み込んでいる:

typescript
import { Database } from "bun:sqlite";
 
// データベースを開くまたは作成
const db = new Database("myapp.db");
 
// 並行読み取り性能向上のためWALモード
db.exec("PRAGMA journal_mode = WAL");
 
// テーブルの作成
db.run(`
  CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    email TEXT UNIQUE NOT NULL,
    name TEXT NOT NULL,
    created_at TEXT DEFAULT (datetime('now'))
  )
`);
 
// プリペアドステートメント(再利用可能、繰り返しクエリで高速)
const insertUser = db.prepare(
  "INSERT INTO users (email, name) VALUES ($email, $name) RETURNING *"
);
 
const findByEmail = db.prepare(
  "SELECT * FROM users WHERE email = $email"
);
 
// 使用方法
const user = insertUser.get({
  $email: "alice@example.com",
  $name: "Alice",
});
console.log(user); // { id: 1, email: "alice@example.com", name: "Alice", ... }
 
// トランザクション
const insertMany = db.transaction((users: { email: string; name: string }[]) => {
  for (const user of users) {
    insertUser.run({ $email: user.email, $name: user.name });
  }
  return users.length;
});
 
const count = insertMany([
  { email: "bob@example.com", name: "Bob" },
  { email: "carol@example.com", name: "Carol" },
]);
console.log(`${count}人のユーザーを挿入`);

これはCライブラリのパフォーマンスを持つ同期SQLiteだ(実際にそうだから — Bunはlibsqlite3を直接埋め込んでいる)。CLIツール、ローカルファーストアプリ、小さなサービスでは、組み込みSQLiteはデータレイヤーの外部依存関係がゼロということを意味する。

Bunテストランナー#

bun test はほとんどの場合、Jestのドロップイン置換だ。同じ describe/it/expect APIを使用し、ほとんどのJestマッチャーをサポートしている。

基本的な使い方#

typescript
// math.test.ts
import { describe, it, expect } from "bun:test";
 
describe("数学ユーティリティ", () => {
  it("数値を正しく加算する", () => {
    expect(1 + 2).toBe(3);
  });
 
  it("浮動小数点を処理する", () => {
    expect(0.1 + 0.2).toBeCloseTo(0.3);
  });
});
bash
# すべてのテストを実行
bun test
 
# 特定のファイルを実行
bun test math.test.ts
 
# パターンに一致するテストを実行
bun test --test-name-pattern "数値を正しく加算"
 
# ウォッチモード
bun test --watch
 
# カバレッジ
bun test --coverage

モック#

BunはJest互換のモックをサポートしている:

typescript
import { describe, it, expect, mock, spyOn } from "bun:test";
import { fetchUsers } from "./api";
 
// モジュールをモック
mock.module("./database", () => ({
  query: mock(() => [{ id: 1, name: "Alice" }]),
}));
 
describe("fetchUsers", () => {
  it("データベースからユーザーを返す", async () => {
    const users = await fetchUsers();
    expect(users).toHaveLength(1);
    expect(users[0].name).toBe("Alice");
  });
});
 
// オブジェクトメソッドをスパイ
describe("console", () => {
  it("console.logの呼び出しを追跡する", () => {
    const logSpy = spyOn(console, "log");
    console.log("テストメッセージ");
    expect(logSpy).toHaveBeenCalledWith("テストメッセージ");
    logSpy.mockRestore();
  });
});

Bun Test vs. Vitest — 正直な比較#

このプロジェクト(そして私のほとんどのプロジェクト)ではVitestを使っている。完全に切り替えていない理由はこうだ:

bun testが勝つところ:

  • 起動速度。bun test はVitestが設定の読み込みを完了する前にテスト実行を開始する。
  • ゼロコンフィグ。基本的なセットアップに vitest.config.ts が不要。
  • 組み込みTypeScript。変換ステップなし。

Vitestがまだ勝つところ:

  • エコシステム:Vitestはプラグインが多く、IDE統合が良く、コミュニティが大きい。
  • 設定:Vitestの設定システムはより柔軟。カスタムレポーター、複雑なセットアップファイル、複数のテスト環境。
  • ブラウザモード:Vitestは実際のブラウザでテストを実行できる。Bunはできない。
  • 互換性:一部のテストライブラリ(Testing Library、MSW)はVitest/Jestでより徹底的にテストされている。
  • スナップショットテスト:両方サポートしているが、Vitestの実装はより成熟しており、diff出力が良い。

シンプルなテストニーズの新プロジェクトなら、bun test を使う。Testing Library、MSW、複雑なモックを持つ既存プロジェクトなら、Vitestを続ける。

Bunバンドラー#

bun build は高速なJavaScript/TypeScriptバンドラーだ。webpackの置換ではない — esbuildのカテゴリに近い:高速で、意見があり、一般的なケースに焦点を当てている。

基本的なバンドリング#

bash
# 単一のエントリーポイントをバンドル
bun build ./src/index.ts --outdir ./dist
 
# 異なるターゲット向けにバンドル
bun build ./src/index.ts --outdir ./dist --target browser
bun build ./src/index.ts --outdir ./dist --target bun
bun build ./src/index.ts --outdir ./dist --target node
 
# ミニファイ
bun build ./src/index.ts --outdir ./dist --minify
 
# ソースマップを生成
bun build ./src/index.ts --outdir ./dist --sourcemap external

プログラマティックAPI#

typescript
const result = await Bun.build({
  entrypoints: ["./src/index.ts", "./src/worker.ts"],
  outdir: "./dist",
  target: "browser",
  minify: {
    whitespace: true,
    identifiers: true,
    syntax: true,
  },
  splitting: true,    // コード分割
  sourcemap: "external",
  external: ["react", "react-dom"],  // これらはバンドルしない
  naming: "[dir]/[name]-[hash].[ext]",
  define: {
    "process.env.NODE_ENV": JSON.stringify("production"),
  },
});
 
if (!result.success) {
  console.error("ビルド失敗:");
  for (const log of result.logs) {
    console.error(log);
  }
  process.exit(1);
}
 
for (const output of result.outputs) {
  console.log(`${output.path} — ${output.size} バイト`);
}

ツリーシェイキング#

BunはESMのツリーシェイキングをサポートしている:

typescript
// utils.ts
export function used() {
  return "バンドルに含まれる";
}
 
export function unused() {
  return "ツリーシェイキングで除去される";
}
 
// index.ts
import { used } from "./utils";
console.log(used());
bash
bun build ./src/index.ts --outdir ./dist --minify
# `unused` 関数は出力に現れない

Bun Buildの限界#

  • CSSバンドリングなし — CSSには別途ツールが必要(PostCSS、Lightning CSS、Tailwind CLI)。
  • HTML生成なし — JavaScript/TypeScriptをバンドルするが、完全なWebアプリではない。
  • プラグインエコシステム — esbuildの方がプラグインエコシステムがはるかに大きい。BunのプラグインAPIは互換だが、コミュニティは小さい。
  • 高度なコード分割 — WebpackとRollupの方がより洗練されたチャンク戦略を提供する。

ライブラリやシンプルなWebアプリのJSバンドルの構築には、bun build は優秀だ。CSSモジュール、画像最適化、カスタムチャンク戦略を含む複雑なアプリビルドには、フルバンドラーが必要だろう。

Bunマクロ#

本当にユニークな機能:マクロによるコンパイル時のコード実行。

typescript
// build-info.ts — このファイルはランタイムではなくビルド時に実行される
export function getBuildInfo() {
  return {
    builtAt: new Date().toISOString(),
    gitSha: require("child_process")
      .execSync("git rev-parse --short HEAD")
      .toString()
      .trim(),
    nodeVersion: process.version,
  };
}
typescript
// app.ts
import { getBuildInfo } from "./build-info" with { type: "macro" };
 
// getBuildInfo()はバンドル時に実行される
// 結果は静的な値としてインライン化される
const info = getBuildInfo();
console.log(`ビルド日時: ${info.builtAt}, コミット: ${info.gitSha}`);

バンドル後、getBuildInfo() はリテラルオブジェクトに置換される — ランタイムでの関数呼び出しも child_process のインポートもない。コードはビルド中に実行され、結果がインライン化された。ビルドメタデータ、フィーチャーフラグ、環境固有の設定の埋め込みに強力だ。

BunとNext.js#

最もよく聞かれる質問なので、具体的に説明しよう。

現在動作するもの#

Next.jsのパッケージマネージャーとしてのBun — 完璧に動作:

bash
# Bunで依存関係をインストールし、Node.jsでNext.jsを実行
bun install
bun run dev    # デフォルトではNode.js経由で「dev」スクリプトを実行
bun run build
bun run start

すべてのNext.jsプロジェクトでこれを行っている。bun run <script> コマンドは package.jsonscripts セクションを読み取り実行する。デフォルトでは、実際の実行にはシステムのNode.jsを使用する。ランタイムを変更せずに、Bunの高速パッケージインストールの恩恵が得られる。

Next.js開発用のBunランタイム:

bash
# Next.jsをBunのランタイムで強制実行
bun --bun run dev

ほとんどの場合、開発で動作する。--bun フラグはNode.jsに委譲する代わりにBun独自のランタイムを使うよう指示する。ホットモジュールリプレースメントが動作する。APIルートが動作する。サーバーコンポーネントが動作する。

まだ実験的なもの#

Next.js本番ビルド用のBunランタイム:

bash
# Bunランタイムでビルド
bun --bun run build
 
# Bunランタイムで本番サーバーを起動
bun --bun run start

多くのプロジェクトで動作するが、エッジケースに遭遇した:

  1. 一部のミドルウェアの動作が異なる — Node.js固有のAPIに依存するNext.jsミドルウェアを使用している場合、互換性の問題に当たる可能性がある。
  2. 画像最適化 — Next.jsの画像最適化パイプラインはネイティブアドオンのsharpを使用する。Bunで動作するが、時折問題が発生した。
  3. ISR(増分静的再生成) — 動作するが、Bunでの本番環境でのストレステストは行っていない。

Next.jsに対する私の推奨#

パッケージマネージャーにBunを使う。ランタイムにNode.jsを使う。これにより、互換性リスクなしに bun install の速度メリットが得られる。

json
{
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build",
    "start": "next start"
  }
}
bash
# 日常のワークフロー
bun install      # 高速パッケージインストール
bun run dev      # Node.js経由で「next dev」を実行
bun run build    # Node.js経由で「next build」を実行

BunのNode.js互換性がNext.jsの内部使用に対して100%に達したら(近いがまだ到達していない)、切り替える。それまでは、パッケージマネージャーだけでインストールを正当化できる十分な時間の節約がある。

DockerでのBun#

公式Bun Dockerイメージはよく管理されており、本番環境対応だ。

基本的なDockerfile#

dockerfile
FROM oven/bun:1 AS base
WORKDIR /app
 
# 依存関係のインストール
FROM base AS deps
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile --production
 
# ビルド(必要な場合)
FROM base AS build
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
 
# 本番環境
FROM base AS production
WORKDIR /app
 
# rootとして実行しない
RUN addgroup --system --gid 1001 appgroup && \
    adduser --system --uid 1001 appuser
USER appuser
 
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./
 
EXPOSE 3000
CMD ["bun", "run", "dist/server.js"]

最小イメージのためのマルチステージビルド#

dockerfile
# ビルドステージ:全依存関係を含むフルBunイメージ
FROM oven/bun:1 AS builder
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun build ./src/index.ts --target bun --outdir ./dist --minify
 
# ランタイムステージ:より小さなベースイメージ
FROM oven/bun:1-slim AS runtime
WORKDIR /app
 
RUN addgroup --system --gid 1001 appgroup && \
    adduser --system --uid 1001 appuser
USER appuser
 
COPY --from=builder /app/dist ./dist
 
EXPOSE 3000
CMD ["bun", "run", "dist/index.js"]

単一バイナリへのコンパイル#

これはデプロイメントにおけるBunのキラー機能の1つだ:

bash
# アプリを単一の実行ファイルにコンパイル
bun build --compile ./src/server.ts --outfile server
 
# 出力はスタンドアロンバイナリ — 実行にBunもNode.jsも不要
./server
dockerfile
# コンパイル済みバイナリを使った超最小Dockerイメージ
FROM oven/bun:1 AS builder
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun build --compile ./src/server.ts --outfile server
 
# 最終イメージ — バイナリだけ
FROM debian:bookworm-slim
WORKDIR /app
 
RUN addgroup --system --gid 1001 appgroup && \
    adduser --system --uid 1001 appuser
USER appuser
 
COPY --from=builder /app/server ./server
 
EXPOSE 3000
CMD ["./server"]

コンパイル済みバイナリは通常50〜90 MB(Bunランタイムをバンドル)。Goバイナリより大きいが、フルNode.jsインストール + node_modules よりはるかに小さい。コンテナ化デプロイメントでは、自己完結型の特性は大幅な簡素化だ。

サイズ比較#

bash
# Node.jsイメージ
docker images | grep node
# node:20-slim    〜180MB
 
# Bunイメージ
docker images | grep bun
# oven/bun:1-slim  〜130MB
 
# debian:bookworm-slim上のコンパイル済みバイナリ
# 〜80MBベース + 〜70MBバイナリ = 合計〜150MB
 
# Alpine + Node.js
# node:20-alpine   〜130MB + node_modules

バイナリアプローチは最終イメージから node_modules を完全に排除する。本番環境での npm install なし。数百パッケージからのサプライチェーン攻撃面なし。ファイル1つだけだ。

移行パターン#

Bunへの移行を検討しているなら、私が推奨する段階的なパスはこうだ:

フェーズ1:パッケージマネージャーのみ(リスクゼロ)#

bash
# npm/yarn/pnpmをbun installに置き換え
# CIパイプラインを変更:
# 変更前:
npm ci
 
# 変更後:
bun install --frozen-lockfile

コード変更なし。ランタイム変更なし。インストールが速くなるだけ。もし何か壊れたら(壊れないが)、bun.lockb を削除して npm install を実行すれば元に戻る。

フェーズ2:スクリプトとツール#

bash
# 開発スクリプトにbunを使用
bun run dev
bun run lint
bun run format
 
# ワンオフスクリプトにbunを使用
bun run scripts/seed-database.ts
bun run scripts/migrate.ts

実際のアプリケーションのランタイムとしてはまだNode.jsを使用。しかしスクリプトはBunのより速い起動とネイティブTypeScriptサポートの恩恵を受ける。

フェーズ3:テストランナー(中リスク)#

bash
# シンプルなテストスイートでvitest/jestをbun testに置き換え
bun test
 
# 複雑なテストセットアップではvitestを維持
# (Testing Library、MSW、カスタム環境)

bun test で完全なテストスイートを実行する。すべてパスすれば、devDependencyを1つ排除できた。互換性の問題で一部のテストが失敗する場合、それらにはVitestを維持し、残りには bun test を使用する。

フェーズ4:新サービスのランタイム(計算されたリスク)#

typescript
// 新しいマイクロサービスやAPI — 最初からBunで始める
Bun.serve({
  port: 3000,
  fetch(req) {
    // 新しいサービスをここに
  },
});

既存のNode.jsサービスをBunランタイムに移行しない。代わりに、最初からBunで新しいサービスを書く。これにより影響範囲が限定される。

フェーズ5:ランタイム移行(上級者向け)#

bash
# 徹底的なテスト後にのみ:
# 既存サービスでnodeをbunに置き換え
# 変更前:
node dist/server.js
 
# 変更後:
bun dist/server.js

優れたテストカバレッジを持つサービスにのみこれを推奨する。本番環境を切り替える前に、Bunで負荷テストを実行すること。

環境変数と設定#

Bunは .env ファイルを自動的に処理する — dotenv パッケージは不要:

bash
# .env
DATABASE_URL=postgresql://localhost:5432/myapp
API_KEY=sk-test-12345
PORT=3000
typescript
// インポートなしでこれらが利用可能
console.log(process.env.DATABASE_URL);
console.log(process.env.API_KEY);
console.log(Bun.env.PORT); // Bun固有の代替手段

Bunは .env.env.local.env.production などを自動的に読み込み、Next.jsと同じ慣例に従う。package.json の依存関係が1つ減る。

エラーハンドリングとデバッグ#

Bunのエラー出力は大幅に改善されたが、一部のケースではまだNode.jsほど洗練されていない:

bash
# Bunのデバッガー — VS Codeで動作
bun --inspect run server.ts
 
# Bunのinspect-brk — 最初の行で一時停止
bun --inspect-brk run server.ts

VS Codeでは、.vscode/launch.json に以下を追加:

json
{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "bun",
      "request": "launch",
      "name": "Debug Bun",
      "program": "${workspaceFolder}/src/server.ts",
      "cwd": "${workspaceFolder}",
      "stopOnEntry": false,
      "watchMode": false
    }
  ]
}

BunのスタックトレースはTypeScriptのソースマップを含み、一般的に正確だ。主なデバッグのギャップは、一部のNode.js固有のデバッグツール(ndbclinic.js など)がBunで動作しないことだ。

セキュリティの考慮事項#

本番環境でBunを評価している場合に考えるべきこと:

成熟度:Node.jsは15年以上本番環境で使われてきた。HTTPパース、TLS処理、ストリーム処理のすべてのエッジケースが発見され修正されている。Bunはより若い。十分にテストされているが、未発見のバグの表面積が大きい。

セキュリティパッチ:Bunチームは頻繁にアップデートを出すが、Node.jsのセキュリティチームは正式なCVEプロセス、協調的な開示、より長い実績がある。セキュリティクリティカルなアプリケーションでは、これが重要だ。

サプライチェーン:Bunの組み込み機能(SQLite、HTTPサーバー、WebSocket)はnpm依存関係が少なくなることを意味する。依存関係が少なければ、サプライチェーン攻撃の表面積が小さくなる。これは本当のセキュリティ上の利点だ。

bash
# 依存関係数の比較
# 典型的なExpress + SQLite + WebSocketプロジェクト:
npm ls --all | wc -l
# 〜340パッケージ
 
# Bunの組み込み機能で同じ機能性:
bun pm ls --all | wc -l
# 〜12パッケージ(アプリケーションコードだけ)

本番ワークロードに信頼するパッケージ数の意味のある削減だ。

パフォーマンスチューニング#

Bun固有のパフォーマンスのヒント:

typescript
// 本番チューニング用のBun.serve()オプション
Bun.serve({
  port: 3000,
 
  // 最大リクエストボディサイズを増加(デフォルトは128MB)
  maxRequestBodySize: 1024 * 1024 * 50, // 50MB
 
  // 開発モードを有効にしてより良いエラーページを表示
  development: process.env.NODE_ENV !== "production",
 
  // ポート再利用(ゼロダウンタイムリスタートに便利)
  reusePort: true,
 
  fetch(req) {
    return new Response("OK");
  },
});
typescript
// ランタイムコード変換にBun.Transpilerを使用
const transpiler = new Bun.Transpiler({
  loader: "tsx",
  target: "browser",
});
 
const code = transpiler.transformSync(`
  const App: React.FC = () => <div>Hello</div>;
  export default App;
`);
bash
# Bunのメモリ使用フラグ
bun --smol run server.ts  # メモリフットプリントを削減(やや遅くなる)
 
# 最大ヒープサイズを設定
BUN_JSC_forceRAMSize=512000000 bun run server.ts  # 〜512MBリミット

よくある落とし穴#

Bunを1年使って、つまずいたこと:

1. グローバルFetchの動作の違い#

typescript
// Node.js 18+のfetchとBunのfetchは
// ヘッダーやリダイレクトの処理がわずかに異なる
 
// Bunはデフォルトでリダイレクトに従う(ブラウザと同様)
// Node.jsのfetchもリダイレクトに従うが、
// 特定のステータスコード(303、307、308)での動作が異なる場合がある
 
const response = await fetch("https://api.example.com/data", {
  redirect: "manual", // リダイレクト処理を明示的にする
});

2. プロセス終了の動作#

typescript
// Bunはイベントループが空になると終了する
// Node.jsは残存するハンドルのため実行を継続する場合がある
 
// Bunスクリプトが予期せず終了する場合、
// イベントループを維持するものがない
 
// これはBunで即座に終了する:
setTimeout(() => {}, 0);
 
// これは実行を継続する:
setTimeout(() => {}, 1000);
// (Bunはタイムアウト発火後に終了する)

3. TypeScript設定#

typescript
// Bunは独自のtsconfigデフォルトを持つ
// BunとNode.jsの間でプロジェクトを共有する場合、
// tsconfig.jsonで明示的に指定する:
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "types": ["bun-types"]  // Bun型定義を追加
  }
}
bash
# Bun型をインストール
bun add -d @types/bun

4. 開発時のホットリロード#

bash
# Bunには組み込みウォッチモードがある
bun --watch run server.ts
 
# ファイル変更時にプロセスを再起動する
# HMR(ホットモジュールリプレースメント)ではない — 完全な再起動
# しかしBunの起動が速いため、即座に感じる

5. bunfig.toml 設定ファイル#

toml
# bunfig.toml — Bunの設定ファイル(オプション)
 
[install]
# プライベートレジストリを使用
registry = "https://npm.mycompany.com"
 
# スコープ付きレジストリ
[install.scopes]
"@mycompany" = "https://npm.mycompany.com"
 
[test]
# テスト設定
coverage = true
coverageReporter = ["text", "lcov"]
 
[run]
# bun runに使用するシェル
shell = "bash"

私の結論#

1年間の本番使用を経て、落ち着いたところ:

現在Bunを使っている場面#

すべてのプロジェクトのパッケージマネージャー — このNext.jsブログを含む。bun install は速く、互換性は本質的に完璧だ。もうnpmやyarnを使う理由がない。pnpmが唯一検討する代替手段だ(モノレポでの厳格な依存関係解決のため)。

スクリプトとCLIツールのランタイム — 一度実行する必要があるTypeScriptファイルは、すべて bun で実行する。コンパイルステップなし。高速起動。組み込み .env 読み込み。ワークフローで ts-nodetsx を完全に置き換えた。

小さなAPIと社内ツールのランタイムBun.serve() + bun:sqlite は、社内ツール、Webhookハンドラー、小さなサービスのための信じられないほど生産的なスタックだ。「1バイナリ、依存関係なし」のデプロイメントモデルは魅力的だ。

シンプルなプロジェクトのテストランナー — テストニーズが単純なプロジェクトでは、bun test は高速でゼロ設定だ。

Node.jsを続ける場面#

本番Next.js — Bunが動かないからではなく、リスクとリターンのバランスがまだ正当化できないから。Next.jsは多くの統合ポイントを持つ複雑なフレームワークだ。その下には最も実戦検証済みのランタイムが欲しい。

重要な本番サービス — メインのAPIサーバーはPM2の背後でNode.jsを実行している。モニタリングエコシステム、デバッグツール、運用知識 — すべてNode.jsだ。Bunはそこに到達するだろうが、まだそこにはない。

ネイティブアドオンを含むもの — 依存関係チェーンにC++ネイティブアドオンが含まれる場合、Bunを試すことすらしない。互換性の問題のデバッグは価値がない。

Bunに慣れていないチーム — Bunをランタイムとしてチームに導入すると、認知的オーバーヘッドが増える。パッケージマネージャーとしてなら問題ない。ランタイムとしてなら、チームの準備ができるまで待つ。

注目していること#

Bunの互換性トラッカー — 私が気にするNode.js APIに対して100%に達したら、再評価する。

フレームワークサポート — Next.js、Remix、SvelteKitはすべてBunサポートのレベルが異なる。そのどれかが公式にBunを本番ランタイムとしてサポートした時、それがシグナルだ。

エンタープライズ採用 — 実際のSLAを持つ企業がBunを本番環境で実行し、それについて書くようになれば、成熟度の疑問は解消される。

1.2+リリースライン — Bunは急速に進化している。毎週機能が追加される。今使っているBunは、1年前に試したBunよりも意味のある改善がある。

まとめ#

Bunは万能薬ではない。遅いアプリを速くすることも、設計が悪いAPIを良い設計にすることもない。しかし、JavaScriptエコシステムの開発者体験の真の改善だ。

Bunで最も評価しているのは、どの単一の機能でもない。ツールチェーンの複雑さの削減だ。パッケージをインストールし、TypeScriptを実行し、コードをバンドルし、テストを実行する1つのバイナリ。スクリプト用の tsconfig.json なし。Babelなし。別のテストランナー設定なし。bun run your-file.ts とすれば動く。

実践的なアドバイス:bun install から始めよう。リスクゼロ、即座の効果。次にスクリプトで bun run を試す。残りは具体的なニーズに基づいて評価する。オールインする必要はない。Bunは部分的な置換として完璧に機能し、それがおそらくほとんどの人が今日使うべき方法だ。

JavaScriptランタイムの景色はBunがあることで良くなっている。競争はNode.jsもより良くしている — Node.js 22+は大幅に速くなった。その一因はBunからのプレッシャーだ。全員が勝者だ。

関連記事