Aller au contenu
·28 min de lecture

Bun en production : ce qui marche, ce qui ne marche pas et ce qui m'a surpris

Bun comme runtime, gestionnaire de paquets, bundler et lanceur de tests. Benchmarks réels, lacunes de compatibilité Node.js, patterns de migration et où j'utilise Bun en production aujourd'hui.

Partager:X / TwitterLinkedIn

Tous les quelques ans, l'écosystème JavaScript se dote d'un nouveau runtime et le discours suit un arc prévisible. Engouement. Benchmarks. « X est mort. » Retour à la réalité. Installation dans les cas d'usage concrets où le nouvel outil brille véritablement.

Bun est au milieu de cet arc en ce moment. Et contrairement à la plupart des challengers, il reste. Non pas parce qu'il est « plus rapide » (bien qu'il le soit souvent), mais parce qu'il résout un problème fondamentalement différent : la chaîne d'outils JavaScript a trop de pièces mobiles, et Bun les regroupe en une seule.

J'utilise Bun à divers titres depuis plus d'un an maintenant. Une partie en production. Une partie en remplacement d'outils que je pensais ne jamais remplacer. Cet article est un bilan honnête de ce qui marche, ce qui ne marche pas, et là où les lacunes comptent encore.

Ce qu'est réellement Bun#

La première idée reçue à corriger : Bun n'est pas « un Node.js plus rapide ». Cette formulation le sous-estime.

Bun est quatre outils en un seul binaire :

  1. Un runtime JavaScript/TypeScript — exécute votre code, comme Node.js ou Deno
  2. Un gestionnaire de paquets — remplace npm, yarn ou pnpm
  3. Un bundler — remplace esbuild, webpack ou Rollup pour certains cas d'usage
  4. Un lanceur de tests — remplace Jest ou Vitest pour la plupart des suites de tests

La différence architecturale clé avec Node.js est le moteur. Node.js utilise V8 (le moteur de Chrome). Bun utilise JavaScriptCore (le moteur de Safari). Les deux sont des moteurs matures et prêts pour la production, mais ils font des compromis différents. JavaScriptCore tend à avoir des temps de démarrage plus rapides et une consommation mémoire plus faible. V8 tend à avoir un meilleur débit de pointe pour les calculs de longue durée. En pratique, ces différences sont plus petites qu'on ne le pense pour la plupart des charges de travail.

L'autre différenciateur majeur : Bun est écrit en Zig, un langage de programmation système qui se situe à peu près au même niveau que le C mais avec de meilleures garanties de sécurité mémoire. C'est pourquoi Bun peut être si agressif sur les performances — Zig vous donne le genre de contrôle bas niveau que le C offre sans la densité de pièges du C.

bash
# Check your Bun version
bun --version
 
# Run a TypeScript file directly — no tsconfig, no compilation step
bun run server.ts
 
# Install packages
bun install
 
# Run tests
bun test
 
# Bundle for production
bun build ./src/index.ts --outdir ./dist

C'est un seul binaire qui fait le travail de node + npm + esbuild + vitest. Qu'on aime ou pas, c'est une réduction convaincante de la complexité.

Les revendications de vitesse — benchmarks honnêtes#

Soyons directs à ce sujet : les benchmarks marketing de Bun sont sélectifs. Pas frauduleux — sélectifs. Ils montrent les scénarios où Bun est le plus performant, ce qui est exactement ce qu'on attend d'un matériel marketing. Le problème est que les gens extrapolent à partir de ces benchmarks pour affirmer que Bun est « 25x plus rapide » en tout, ce qui est absolument faux.

Voici où Bun est véritablement, significativement plus rapide :

Temps de démarrage#

C'est le plus grand avantage réel de Bun et ce n'est même pas comparable.

bash
# Measuring startup time — run each 100 times
hyperfine --warmup 5 'node -e "console.log(1)"' 'bun -e "console.log(1)"'
 
# Typical results:
# node:  ~40ms
# bun:   ~6ms

C'est environ une différence de 6-7x en temps de démarrage. Pour les scripts, les outils CLI et les fonctions serverless où le cold start compte, c'est significatif. Pour un processus serveur de longue durée qui démarre une fois et tourne pendant des semaines, c'est sans importance.

Installation de paquets#

C'est l'autre domaine où Bun écrase la concurrence.

bash
# Clean install benchmark — delete node_modules and lockfile first
rm -rf node_modules bun.lockb package-lock.json
 
# Time npm
time npm install
# Real: ~18.4s (typical medium-sized project)
 
# Time bun
time bun install
# Real: ~2.1s

C'est une différence de 8-9x, et c'est constant. Les raisons sont principalement :

  1. Fichier de verrouillage binairebun.lockb est un format binaire, pas du JSON. Plus rapide à lire et écrire.
  2. Cache global — Bun maintient un cache global de modules pour que les réinstallations entre projets partagent les paquets téléchargés.
  3. Les E/S de Zig — Le gestionnaire de paquets lui-même est écrit en Zig, pas en JavaScript. Les opérations d'E/S fichier sont plus proches du métal.
  4. Stratégie de liens symboliques — Bun utilise des liens durs depuis le cache global au lieu de copier les fichiers.

Débit du serveur HTTP#

Le serveur HTTP intégré de Bun est rapide, mais les comparaisons nécessitent du contexte.

bash
# Quick and dirty benchmark with bombardier
# Testing a simple "Hello World" response
 
# Bun server
bombardier -c 100 -d 10s http://localhost:3000
# Requests/sec: ~105,000
 
# Node.js (native http module)
bombardier -c 100 -d 10s http://localhost:3001
# Requests/sec: ~48,000
 
# Node.js (Express)
bombardier -c 100 -d 10s http://localhost:3002
# Requests/sec: ~15,000

Bun vs. Node.js brut : environ 2x pour des réponses triviales. Bun vs. Express : environ 7x, mais c'est injuste car Express ajoute du surcoût de middleware. Dès que vous ajoutez de la vraie logique — requêtes base de données, authentification, sérialisation JSON de données réelles — l'écart se réduit considérablement.

Là où la différence est négligeable#

Calcul intensif en CPU :

typescript
// fibonacci.ts — this is engine-bound, not runtime-bound
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) gagne en fait légèrement ici. Le compilateur JIT de V8 est plus agressif sur les boucles chaudes. Pour le travail intensif en CPU, les différences de moteur sont négligeables — parfois V8 gagne, parfois JSC gagne, et les différences sont dans le bruit.

Comment faire vos propres benchmarks#

Ne faites confiance aux benchmarks de personne, y compris les miens. Voici comment mesurer ce qui compte pour votre charge de travail spécifique :

bash
# Install hyperfine for proper benchmarking
brew install hyperfine  # macOS
# or: cargo install hyperfine
 
# Benchmark startup + execution of your actual app
hyperfine --warmup 3 \
  'node dist/server.js' \
  'bun src/server.ts' \
  --prepare 'sleep 0.1'
 
# For HTTP servers, use bombardier or wrk
# Important: test with realistic payloads, not "Hello World"
bombardier -c 50 -d 30s -l http://localhost:3000/api/users
 
# Memory comparison
/usr/bin/time -v node server.js  # Linux
/usr/bin/time -l bun server.ts   # macOS

La règle générale : si votre goulot d'étranglement est l'E/S (système de fichiers, réseau, base de données), l'avantage de Bun est modeste. Si votre goulot d'étranglement est le temps de démarrage ou la vitesse de la chaîne d'outils, Bun gagne gros. Si votre goulot d'étranglement est le calcul brut, c'est à peu près égal.

Bun comme gestionnaire de paquets#

C'est là que j'ai complètement basculé. Même sur les projets où j'exécute Node.js en production, j'utilise bun install pour le développement local et la CI. C'est tout simplement plus rapide, et la compatibilité est excellente.

Les bases#

bash
# Install all dependencies from package.json
bun install
 
# Add a dependency
bun add express
 
# Add a dev dependency
bun add -d vitest
 
# Remove a dependency
bun remove express
 
# Update a dependency
bun update express
 
# Install a specific version
bun add express@4.18.2

Si vous avez utilisé npm ou yarn, c'est entièrement familier. Les flags sont légèrement différents (-d au lieu de --save-dev), mais le modèle mental est identique.

La situation du fichier de verrouillage#

Bun utilise bun.lockb, un fichier de verrouillage binaire. C'est à la fois son super-pouvoir et son plus gros point de friction.

Le bon : Il est considérablement plus rapide à lire et écrire. Le format binaire signifie que Bun peut parser le fichier de verrouillage en microsecondes, pas les centaines de millisecondes que npm passe à parser package-lock.json.

Le mauvais : Vous ne pouvez pas le relire dans un diff. Si vous êtes en équipe et que quelqu'un met à jour une dépendance, vous ne pouvez pas regarder le diff du fichier de verrouillage dans une PR et voir ce qui a changé. C'est plus important que les partisans de la vitesse ne veulent l'admettre.

bash
# You can dump the lockfile to human-readable format
bun bun.lockb > lockfile-dump.txt
 
# Or use the built-in text output
bun install --yarn
# This generates a yarn.lock alongside bun.lockb

Mon approche : je commite bun.lockb dans le dépôt et je génère aussi un yarn.lock ou package-lock.json comme alternative lisible. Ceinture et bretelles.

Support des workspaces#

Bun supporte les workspaces de style npm/yarn :

json
{
  "name": "my-monorepo",
  "workspaces": [
    "packages/*",
    "apps/*"
  ]
}
bash
# Install dependencies for all workspaces
bun install
 
# Run a script in a specific workspace
bun run --filter packages/shared build
 
# Add a dependency to a specific workspace
bun add react --filter apps/web

Le support des workspaces est solide et s'est considérablement amélioré. La lacune principale comparée à pnpm est que la résolution des dépendances de workspace de Bun est moins stricte — la rigueur de pnpm est une fonctionnalité pour les monorepos car elle détecte les dépendances fantômes.

Compatibilité avec les projets existants#

Vous pouvez intégrer bun install dans pratiquement n'importe quel projet Node.js existant. Il lit package.json, respecte .npmrc pour la configuration du registre, et gère les peerDependencies correctement. La transition est typiquement :

bash
# Step 1: Delete existing lockfile and node_modules
rm -rf node_modules package-lock.json yarn.lock pnpm-lock.yaml
 
# Step 2: Install with Bun
bun install
 
# Step 3: Verify your app still works
bun run dev
# or: node dist/server.js  (Bun package manager, Node runtime)

J'ai fait cela sur une douzaine de projets et n'ai eu aucun problème avec le gestionnaire de paquets lui-même. Le seul piège est si votre pipeline CI cherche spécifiquement package-lock.json — vous devrez le mettre à jour pour gérer bun.lockb.

Compatibilité Node.js#

C'est la section où je dois être le plus prudent, car la situation change chaque mois. En ce début 2026, voici le tableau honnête.

Ce qui marche#

La grande majorité des paquets npm fonctionnent sans modification. Bun implémente la plupart des modules intégrés de Node.js :

typescript
// These all work as expected in 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 et ESM fonctionnent tous les deux. require() et import peuvent coexister. TypeScript s'exécute sans aucune étape de compilation — Bun supprime les types au moment du parsing.

Frameworks qui fonctionnent :

  • Express — fonctionne, y compris l'écosystème de middleware
  • Fastify — fonctionne
  • Hono — fonctionne (et est excellent avec Bun)
  • Next.js — fonctionne avec des réserves (plus de détails ci-dessous)
  • Prisma — fonctionne
  • Drizzle ORM — fonctionne
  • Socket.io — fonctionne

Ce qui ne marche pas (ou a des problèmes)#

Les lacunes tendent à se répartir en quelques catégories :

Addons natifs (node-gyp) : Si un paquet utilise des addons C++ compilés avec node-gyp, il pourrait ne pas fonctionner avec Bun. Bun a son propre système FFI et supporte de nombreux modules natifs, mais la couverture n'est pas à 100%. Par exemple, bcrypt (la version native) a eu des problèmes — utilisez bcryptjs à la place.

bash
# Check if a package uses native addons
ls node_modules/your-package/binding.gyp  # If this exists, it's native

Certains mécanismes internes de Node.js : Certains paquets accèdent aux internes de Node.js comme process.binding() ou utilisent des API spécifiques à V8. Ceux-ci ne fonctionneront pas avec Bun puisqu'il tourne sur JavaScriptCore.

typescript
// This will NOT work in Bun — V8-specific
const v8 = require("v8");
v8.serialize({ data: "test" });
 
// This WILL work — use Bun's equivalent or a cross-runtime approach
const encoded = new TextEncoder().encode(JSON.stringify({ data: "test" }));

Worker threads : Bun supporte les Web Workers et node:worker_threads, mais il y a des cas limites. Certains patterns d'usage avancés — en particulier autour de SharedArrayBuffer et Atomics — peuvent se comporter différemment.

Module vm : node:vm a un support partiel. Si votre code ou une dépendance utilise extensivement vm.createContext() (certains moteurs de templates le font), testez minutieusement.

Le suivi de compatibilité#

Bun maintient un suivi de compatibilité officiel. Vérifiez-le avant de vous engager avec Bun pour un projet :

bash
# Run Bun's built-in compatibility check on your project
bun --bun node_modules/.bin/your-tool
 
# The --bun flag forces Bun's runtime even for node_modules scripts

Ma recommandation : ne présumez pas la compatibilité. Exécutez votre suite de tests sous Bun avant de décider. Ça prend cinq minutes et économise des heures de débogage.

bash
# Quick compatibility check — run your full test suite under Bun
bun test  # If you use bun test runner
# or
bun run vitest  # If you use vitest

Les API intégrées de Bun#

C'est là que Bun devient intéressant. Au lieu de simplement réimplémenter les API Node.js, Bun fournit ses propres API conçues pour être plus simples et plus rapides.

Bun.serve() — le serveur HTTP intégré#

C'est l'API que j'utilise le plus. Elle est propre, rapide, et le support WebSocket est intégré directement.

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(`Server running at http://localhost:${server.port}`);

Quelques points à noter :

  1. Request/Response conformes aux standards web — pas d'API propriétaire. Le handler fetch reçoit un Request standard et retourne un Response standard. Si vous avez écrit un Cloudflare Worker, c'est identique.
  2. Response.json() — helper intégré pour les réponses JSON.
  3. Aucun import nécessaireBun.serve est un global. Pas de require("http").

Voici un exemple plus réaliste avec du routage, du parsing de body JSON et de la gestion d'erreurs :

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: "Title is required" }, { 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: "Not found" }, { status: 404 });
    } catch (error) {
      console.error("Request error:", error);
      return Response.json({ error: "Internal server error" }, { status: 500 });
    }
  },
});
 
console.log(`Server running on port ${server.port}`);

C'est une API CRUD complète avec SQLite en environ 50 lignes. Pas d'Express, pas d'ORM, pas de chaîne de middleware. Pour les petites API et les outils internes, c'est mon setup de référence maintenant.

Bun.file() et Bun.write() — E/S fichier#

L'API fichier de Bun est rafraîchissante de simplicité comparée à fs.readFile() :

typescript
// Reading files
const file = Bun.file("./config.json");
const text = await file.text();       // Read as string
const json = await file.json();       // Parse as JSON directly
const bytes = await file.arrayBuffer(); // Read as ArrayBuffer
const stream = file.stream();          // Read as ReadableStream
 
// File metadata
console.log(file.size);  // Size in bytes
console.log(file.type);  // MIME type (e.g., "application/json")
 
// Writing files
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"));
 
// Write a Response body to a file
const response = await fetch("https://example.com/data.json");
await Bun.write("./downloaded.json", response);

L'API Bun.file() est paresseuse — elle ne lit pas le fichier tant que vous n'appelez pas .text(), .json(), etc. Cela signifie que vous pouvez passer des références Bun.file() sans encourir de coûts d'E/S tant que vous n'avez pas réellement besoin des données.

Support WebSocket intégré#

Les WebSockets sont de première classe dans Bun.serve() :

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 upgrade failed", { status: 400 });
      }
      return undefined;
    }
 
    return new Response("Use /ws for WebSocket connections");
  },
 
  websocket: {
    open(ws) {
      console.log(`Client connected: ${ws.data.userId}`);
      ws.subscribe("chat");
    },
 
    message(ws, message) {
      // Broadcast to all subscribers
      server.publish("chat", `${ws.data.userId}: ${message}`);
    },
 
    close(ws) {
      console.log(`Client disconnected: ${ws.data.userId}`);
      ws.unsubscribe("chat");
    },
  },
});

Le pattern server.publish() et ws.subscribe() est un pub/sub intégré. Pas de Redis, pas de bibliothèque WebSocket séparée. Pour les fonctionnalités temps réel simples, c'est incroyablement pratique.

SQLite intégré avec bun:sqlite#

C'est ce qui m'a le plus surpris. Bun embarque SQLite directement dans le runtime :

typescript
import { Database } from "bun:sqlite";
 
// Open or create a database
const db = new Database("myapp.db");
 
// WAL mode for better concurrent read performance
db.exec("PRAGMA journal_mode = WAL");
 
// Create tables
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'))
  )
`);
 
// Prepared statements (reusable, faster for repeated queries)
const insertUser = db.prepare(
  "INSERT INTO users (email, name) VALUES ($email, $name) RETURNING *"
);
 
const findByEmail = db.prepare(
  "SELECT * FROM users WHERE email = $email"
);
 
// Usage
const user = insertUser.get({
  $email: "alice@example.com",
  $name: "Alice",
});
console.log(user); // { id: 1, email: "alice@example.com", name: "Alice", ... }
 
// Transactions
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(`Inserted ${count} users`);

C'est du SQLite synchrone avec les performances d'une bibliothèque C (parce que c'en est une — Bun embarque libsqlite3 directement). Pour les outils CLI, les apps local-first et les petits services, SQLite intégré signifie zéro dépendance externe pour votre couche de données.

Lanceur de tests Bun#

bun test est un remplacement direct de Jest dans la plupart des cas. Il utilise la même API describe/it/expect et supporte la plupart des matchers Jest.

Utilisation de base#

typescript
// math.test.ts
import { describe, it, expect } from "bun:test";
 
describe("math utilities", () => {
  it("adds numbers correctly", () => {
    expect(1 + 2).toBe(3);
  });
 
  it("handles floating point", () => {
    expect(0.1 + 0.2).toBeCloseTo(0.3);
  });
});
bash
# Run all tests
bun test
 
# Run specific file
bun test math.test.ts
 
# Run tests matching a pattern
bun test --test-name-pattern "adds numbers"
 
# Watch mode
bun test --watch
 
# Coverage
bun test --coverage

Mocking#

Bun supporte le mocking compatible Jest :

typescript
import { describe, it, expect, mock, spyOn } from "bun:test";
import { fetchUsers } from "./api";
 
// Mock a module
mock.module("./database", () => ({
  query: mock(() => [{ id: 1, name: "Alice" }]),
}));
 
describe("fetchUsers", () => {
  it("returns users from database", async () => {
    const users = await fetchUsers();
    expect(users).toHaveLength(1);
    expect(users[0].name).toBe("Alice");
  });
});
 
// Spy on an object method
describe("console", () => {
  it("tracks console.log calls", () => {
    const logSpy = spyOn(console, "log");
    console.log("test message");
    expect(logSpy).toHaveBeenCalledWith("test message");
    logSpy.mockRestore();
  });
});

Bun Test vs. Vitest — ma comparaison honnête#

J'utilise Vitest pour ce projet (et la plupart de mes projets). Voici pourquoi je n'ai pas complètement basculé :

Là où bun test gagne :

  • Vitesse de démarrage. bun test commence à exécuter les tests avant que Vitest n'ait fini de charger sa configuration.
  • Zéro configuration. Pas de vitest.config.ts nécessaire pour les setups basiques.
  • TypeScript intégré. Pas d'étape de transformation.

Là où Vitest gagne encore :

  • Écosystème : Vitest a plus de plugins, une meilleure intégration IDE et une plus grande communauté.
  • Configuration : Le système de configuration de Vitest est plus flexible. Reporters personnalisés, fichiers de setup complexes, environnements de test multiples.
  • Mode navigateur : Vitest peut exécuter des tests dans un vrai navigateur. Bun ne peut pas.
  • Compatibilité : Certaines bibliothèques de test (Testing Library, MSW) ont été testées plus en profondeur avec Vitest/Jest.
  • Tests de snapshot : Les deux le supportent, mais l'implémentation de Vitest est plus mature avec un meilleur affichage des diffs.

Pour un nouveau projet avec des besoins de test simples, j'utiliserais bun test. Pour un projet établi avec Testing Library, MSW et du mocking complexe, je garde Vitest.

Bundler Bun#

bun build est un bundler JavaScript/TypeScript rapide. Ce n'est pas un remplacement de webpack — c'est plutôt dans la catégorie esbuild : rapide, opinionné et focalisé sur les cas courants.

Bundling de base#

bash
# Bundle a single entry point
bun build ./src/index.ts --outdir ./dist
 
# Bundle for different targets
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
 
# Minify
bun build ./src/index.ts --outdir ./dist --minify
 
# Generate sourcemaps
bun build ./src/index.ts --outdir ./dist --sourcemap external

API programmatique#

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,    // Code splitting
  sourcemap: "external",
  external: ["react", "react-dom"],  // Don't bundle these
  naming: "[dir]/[name]-[hash].[ext]",
  define: {
    "process.env.NODE_ENV": JSON.stringify("production"),
  },
});
 
if (!result.success) {
  console.error("Build failed:");
  for (const log of result.logs) {
    console.error(log);
  }
  process.exit(1);
}
 
for (const output of result.outputs) {
  console.log(`${output.path} — ${output.size} bytes`);
}

Tree-Shaking#

Bun supporte le tree-shaking pour ESM :

typescript
// utils.ts
export function used() {
  return "I'll be in the bundle";
}
 
export function unused() {
  return "I'll be tree-shaken away";
}
 
// index.ts
import { used } from "./utils";
console.log(used());
bash
bun build ./src/index.ts --outdir ./dist --minify
# The `unused` function won't appear in the output

Où Bun Build est insuffisant#

  • Pas de bundling CSS — Vous avez besoin d'un outil séparé pour le CSS (PostCSS, Lightning CSS, Tailwind CLI).
  • Pas de génération HTML — Il bundle du JavaScript/TypeScript, pas des applications web complètes.
  • Écosystème de plugins — esbuild a un écosystème de plugins beaucoup plus large. L'API de plugins de Bun est compatible mais la communauté est plus petite.
  • Code splitting avancé — Webpack et Rollup offrent encore des stratégies de chunks plus sophistiquées.

Pour construire une bibliothèque ou le bundle JS d'une app web simple, bun build est excellent. Pour des builds d'applications complexes avec CSS modules, optimisation d'images et stratégies de chunks personnalisées, vous aurez toujours besoin d'un bundler complet.

Macros Bun#

Une fonctionnalité véritablement unique : l'exécution de code à la compilation via les macros.

typescript
// build-info.ts — this file runs at BUILD TIME, not runtime
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() executes at bundle time
// The result is inlined as a static value
const info = getBuildInfo();
console.log(`Built at ${info.builtAt}, commit ${info.gitSha}`);

Après le bundling, getBuildInfo() est remplacé par l'objet littéral — pas d'appel de fonction au runtime, pas d'import de child_process. Le code s'est exécuté pendant le build et le résultat a été intégré en ligne. C'est puissant pour embarquer des métadonnées de build, des feature flags ou de la configuration spécifique à l'environnement.

Utiliser Bun avec Next.js#

C'est la question qu'on me pose le plus, alors soyons très précis.

Ce qui marche aujourd'hui#

Bun comme gestionnaire de paquets pour Next.js — fonctionne parfaitement :

bash
# Use Bun to install dependencies, then use Node.js to run Next.js
bun install
bun run dev    # This actually runs the "dev" script via Node.js by default
bun run build
bun run start

C'est ce que je fais pour chaque projet Next.js. La commande bun run <script> lit la section scripts de package.json et l'exécute. Par défaut, elle utilise le Node.js du système pour l'exécution réelle. Vous bénéficiez de l'installation rapide des paquets de Bun sans changer votre runtime.

Runtime Bun pour le développement Next.js :

bash
# Force Next.js to run under Bun's runtime
bun --bun run dev

Cela fonctionne pour le développement dans la plupart des cas. Le flag --bun dit à Bun d'utiliser son propre runtime au lieu de déléguer à Node.js. Le hot module replacement fonctionne. Les routes API fonctionnent. Les server components fonctionnent.

Ce qui est encore expérimental#

Runtime Bun pour les builds de production Next.js :

bash
# Build with Bun runtime
bun --bun run build
 
# Start production server with Bun runtime
bun --bun run start

Cela fonctionne pour de nombreux projets mais j'ai rencontré des cas limites :

  1. Certains comportements de middleware diffèrent — Si vous utilisez du middleware Next.js qui dépend d'API spécifiques à Node.js, vous pourriez rencontrer des problèmes de compatibilité.
  2. Optimisation d'images — Le pipeline d'optimisation d'images de Next.js utilise sharp, qui est un addon natif. Il fonctionne avec Bun, mais j'ai vu des problèmes occasionnels.
  3. ISR (Incremental Static Regeneration) — Fonctionne, mais je ne l'ai pas testé en conditions de charge sous Bun en production.

Ma recommandation pour Next.js#

Utilisez Bun comme gestionnaire de paquets. Utilisez Node.js comme runtime. Cela vous donne les avantages de vitesse de bun install sans aucun risque de compatibilité.

json
{
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build",
    "start": "next start"
  }
}
bash
# Daily workflow
bun install      # Fast package installation
bun run dev      # Runs "next dev" via Node.js
bun run build    # Runs "next build" via Node.js

Quand la compatibilité de Bun avec Node.js atteindra 100% pour l'usage interne de Next.js (c'est proche, mais pas encore là), je basculerai. D'ici là, le gestionnaire de paquets seul me fait gagner assez de temps pour justifier l'installation.

Docker avec Bun#

L'image Docker officielle de Bun est bien maintenue et prête pour la production.

Dockerfile de base#

dockerfile
FROM oven/bun:1 AS base
WORKDIR /app
 
# Install dependencies
FROM base AS deps
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile --production
 
# Build (if needed)
FROM base AS build
COPY package.json bun.lockb ./
RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
 
# Production
FROM base AS production
WORKDIR /app
 
# Don't run as 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"]

Build multi-étapes pour une image minimale#

dockerfile
# Build stage: full Bun image with all dependencies
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
 
# Runtime stage: smaller base image
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"]

Compilation en un seul binaire#

C'est l'une des fonctionnalités phares de Bun pour le déploiement :

bash
# Compile your app into a single executable
bun build --compile ./src/server.ts --outfile server
 
# The output is a standalone binary — no Bun or Node.js needed to run it
./server
dockerfile
# Ultra-minimal Docker image using compiled binary
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
 
# Final image — just the binary
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"]

Le binaire compilé fait typiquement 50-90 Mo (il inclut le runtime Bun). C'est plus grand qu'un binaire Go mais beaucoup plus petit qu'une installation Node.js complète plus node_modules. Pour les déploiements conteneurisés, la nature autonome est une simplification significative.

Comparaison de taille#

bash
# Node.js image
docker images | grep node
# node:20-slim    ~180MB
 
# Bun image
docker images | grep bun
# oven/bun:1-slim  ~130MB
 
# Compiled binary on debian:bookworm-slim
# ~80MB base + ~70MB binary = ~150MB total
 
# vs. Alpine with Node.js
# node:20-alpine   ~130MB + node_modules

L'approche binaire élimine node_modules entièrement de l'image finale. Pas de npm install en production. Pas de surface d'attaque de la chaîne d'approvisionnement provenant de centaines de paquets. Juste un seul fichier.

Patterns de migration#

Si vous envisagez de passer à Bun, voici le chemin incrémental que je recommande :

Phase 1 : gestionnaire de paquets uniquement (zéro risque)#

bash
# Replace npm/yarn/pnpm with bun install
# Change your CI pipeline:
# Before:
npm ci
 
# After:
bun install --frozen-lockfile

Aucun changement de code. Aucun changement de runtime. Juste des installations plus rapides. Si quelque chose casse (ce ne sera pas le cas), revenez en arrière en supprimant bun.lockb et en exécutant npm install.

Phase 2 : scripts et outillage#

bash
# Use bun to run development scripts
bun run dev
bun run lint
bun run format
 
# Use bun for one-off scripts
bun run scripts/seed-database.ts
bun run scripts/migrate.ts

Toujours en utilisant Node.js comme runtime pour votre application réelle. Mais les scripts bénéficient du démarrage plus rapide de Bun et du support natif de TypeScript.

Phase 3 : lanceur de tests (risque moyen)#

bash
# Replace vitest/jest with bun test for simple test suites
bun test
 
# Keep vitest for complex test setups
# (Testing Library, MSW, custom environments)

Exécutez votre suite de tests complète sous bun test. Si tout passe, vous avez éliminé une devDependency. Si certains tests échouent à cause de la compatibilité, gardez Vitest pour ceux-là et utilisez bun test pour le reste.

Phase 4 : runtime pour les nouveaux services (risque calculé)#

typescript
// New microservices or APIs — start with Bun from day one
Bun.serve({
  port: 3000,
  fetch(req) {
    // Your new service here
  },
});

Ne migrez pas les services Node.js existants vers le runtime Bun. À la place, écrivez les nouveaux services avec Bun dès le départ. Cela limite votre rayon d'impact.

Phase 5 : migration du runtime (avancé)#

bash
# Only after thorough testing:
# Replace node with bun for existing services
# Before:
node dist/server.js
 
# After:
bun dist/server.js

Je ne recommande cela que pour les services avec une excellente couverture de tests. Exécutez vos tests de charge sous Bun avant de basculer la production.

Variables d'environnement et configuration#

Bun gère les fichiers .env automatiquement — pas besoin du paquet dotenv :

bash
# .env
DATABASE_URL=postgresql://localhost:5432/myapp
API_KEY=sk-test-12345
PORT=3000
typescript
// These are available without any import
console.log(process.env.DATABASE_URL);
console.log(process.env.API_KEY);
console.log(Bun.env.PORT); // Bun-specific alternative

Bun charge .env, .env.local, .env.production, etc. automatiquement, en suivant la même convention que Next.js. Une dépendance de moins dans votre package.json.

Gestion des erreurs et débogage#

La sortie d'erreurs de Bun s'est considérablement améliorée, mais elle n'est toujours pas aussi polie que celle de Node.js dans certains cas :

bash
# Bun's debugger — works with VS Code
bun --inspect run server.ts
 
# Bun's inspect-brk — pause on first line
bun --inspect-brk run server.ts

Pour VS Code, ajoutez ceci à votre .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
    }
  ]
}

Les traces de pile dans Bun sont généralement précises et incluent les source maps pour TypeScript. La principale lacune de débogage est que certains outils de débogage spécifiques à Node.js (comme ndb ou clinic.js) ne fonctionnent pas avec Bun.

Considérations de sécurité#

Quelques points à considérer si vous évaluez Bun pour la production :

Maturité : Node.js est en production depuis plus de 15 ans. Chaque cas limite dans le parsing HTTP, la gestion TLS et le traitement des streams a été trouvé et corrigé. Bun est plus jeune. Il est bien testé, mais la surface pour des bugs non découverts est plus grande.

Correctifs de sécurité : L'équipe Bun livre des mises à jour fréquemment, mais l'équipe de sécurité Node.js a un processus CVE formel, une divulgation coordonnée et un historique plus long. Pour les applications critiques en sécurité, c'est important.

Chaîne d'approvisionnement : Les fonctionnalités intégrées de Bun (SQLite, serveur HTTP, WebSockets) signifient moins de dépendances npm. Moins de dépendances signifie une surface d'attaque de la chaîne d'approvisionnement plus réduite. C'est un véritable avantage en sécurité.

bash
# Compare dependency counts
# A typical Express + SQLite + WebSocket project:
npm ls --all | wc -l
# ~340 packages
 
# The same functionality with Bun built-ins:
bun pm ls --all | wc -l
# ~12 packages (just your application code)

C'est une réduction significative du nombre de paquets auxquels vous faites confiance pour votre charge de travail en production.

Optimisation des performances#

Quelques conseils de performance spécifiques à Bun :

typescript
// Use Bun.serve() options for production tuning
Bun.serve({
  port: 3000,
 
  // Increase max request body size (default is 128MB)
  maxRequestBodySize: 1024 * 1024 * 50, // 50MB
 
  // Enable development mode for better error pages
  development: process.env.NODE_ENV !== "production",
 
  // Reuse port (useful for zero-downtime restarts)
  reusePort: true,
 
  fetch(req) {
    return new Response("OK");
  },
});
typescript
// Use Bun.Transpiler for runtime code transformation
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's memory usage flags
bun --smol run server.ts  # Reduce memory footprint (slightly slower)
 
# Set max heap size
BUN_JSC_forceRAMSize=512000000 bun run server.ts  # ~512MB limit

Pièges courants#

Après un an d'utilisation de Bun, voici les choses qui m'ont fait trébucher :

1. Le comportement du Fetch global diffère#

typescript
// Node.js 18+ fetch and Bun's fetch are slightly different
// in how they handle certain headers and redirects
 
// Bun follows redirects by default (like browsers)
// Node.js fetch also follows redirects, but the behavior
// with certain status codes (303, 307, 308) can differ
 
const response = await fetch("https://api.example.com/data", {
  redirect: "manual", // Be explicit about redirect handling
});

2. Comportement de sortie du processus#

typescript
// Bun exits when the event loop is empty
// Node.js sometimes keeps running due to lingering handles
 
// If your Bun script exits unexpectedly, something isn't
// keeping the event loop alive
 
// This will exit immediately in Bun:
setTimeout(() => {}, 0);
 
// This will keep running:
setTimeout(() => {}, 1000);
// (Bun exits after the timeout fires)

3. Configuration TypeScript#

typescript
// Bun has its own tsconfig defaults
// If you're sharing a project between Bun and Node.js,
// be explicit in your tsconfig.json:
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "types": ["bun-types"]  // Add Bun type definitions
  }
}
bash
# Install Bun types
bun add -d @types/bun

4. Hot Reload en développement#

bash
# Bun has built-in watch mode
bun --watch run server.ts
 
# This restarts the process on file changes
# It's not HMR (Hot Module Replacement) — it's a full restart
# But because Bun starts so fast, it feels instant

5. Le fichier de configuration bunfig.toml#

toml
# bunfig.toml — Bun's config file (optional)
 
[install]
# Use a private registry
registry = "https://npm.mycompany.com"
 
# Scoped registries
[install.scopes]
"@mycompany" = "https://npm.mycompany.com"
 
[test]
# Test configuration
coverage = true
coverageReporter = ["text", "lcov"]
 
[run]
# Shell to use for bun run
shell = "bash"

Mon verdict#

Après un an d'utilisation en production, voici où j'en suis :

Où j'utilise Bun aujourd'hui#

Gestionnaire de paquets pour tous les projets — y compris ce blog Next.js. bun install est plus rapide, et la compatibilité est essentiellement parfaite. Je ne vois aucune raison d'utiliser encore npm ou yarn. pnpm est la seule alternative que j'envisagerais (pour sa résolution stricte des dépendances dans les monorepos).

Runtime pour les scripts et les outils CLI — Tout fichier TypeScript que j'ai besoin d'exécuter une fois, je le lance avec bun. Pas d'étape de compilation. Démarrage rapide. Chargement .env intégré. Il a remplacé ts-node et tsx dans mon workflow entièrement.

Runtime pour les petites API et les outils internesBun.serve() + bun:sqlite est un stack incroyablement productif pour les outils internes, les handlers de webhook et les petits services. Le modèle de déploiement « un binaire, zéro dépendance » est convaincant.

Lanceur de tests pour les projets simples — Pour les projets avec des besoins de test simples, bun test est rapide et ne nécessite aucune configuration.

Où je reste avec Node.js#

Production Next.js — Non pas parce que Bun ne fonctionne pas, mais parce que le rapport risque-récompense ne le justifie pas encore. Next.js est un framework complexe avec de nombreux points d'intégration. Je veux le runtime le plus éprouvé au combat en dessous.

Services de production critiques — Mes serveurs API principaux tournent sous Node.js derrière PM2. L'écosystème de monitoring, les outils de débogage, les connaissances opérationnelles — tout est Node.js. Bun y arrivera, mais il n'y est pas encore.

Tout ce qui a des addons natifs — Si une chaîne de dépendances inclut des addons natifs C++, je n'essaie même pas Bun. Ça ne vaut pas la peine de déboguer les problèmes de compatibilité.

Les équipes qui ne connaissent pas Bun — Introduire Bun comme runtime dans une équipe qui ne l'a jamais utilisé ajoute de la charge cognitive. Comme gestionnaire de paquets, d'accord. Comme runtime, attendez que l'équipe soit prête.

Ce que je surveille#

Le suivi de compatibilité de Bun — Quand il atteindra 100% pour les API Node.js qui me sont importantes, je réévaluerai.

Le support des frameworks — Next.js, Remix et SvelteKit ont tous des niveaux variables de support Bun. Quand l'un d'entre eux supportera officiellement Bun comme runtime de production, ce sera un signal.

L'adoption par les entreprises — Une fois que des entreprises avec de vrais SLA exécutent Bun en production et en parlent, la question de la maturité sera résolue.

La ligne de versions 1.2+ — Bun évolue vite. Des fonctionnalités arrivent chaque semaine. Le Bun que j'utilise aujourd'hui est significativement meilleur que le Bun que j'ai essayé il y a un an.

Pour conclure#

Bun n'est pas une solution miracle. Il ne rendra pas une application lente rapide et ne rendra pas une API mal conçue bien conçue. Mais c'est une amélioration réelle de l'expérience développeur pour l'écosystème JavaScript.

Ce que j'apprécie le plus chez Bun n'est pas une fonctionnalité isolée. C'est la réduction de la complexité de la chaîne d'outils. Un seul binaire qui installe des paquets, exécute du TypeScript, bundle du code et lance des tests. Pas de tsconfig.json pour les scripts. Pas de Babel. Pas de configuration séparée pour le lanceur de tests. Juste bun run your-file.ts et ça marche.

Le conseil pratique : commencez par bun install. C'est zéro risque, bénéfice immédiat. Ensuite essayez bun run pour les scripts. Puis évaluez le reste en fonction de vos besoins spécifiques. Vous n'avez pas besoin de tout miser. Bun fonctionne parfaitement bien comme remplacement partiel, et c'est probablement la façon dont la plupart des gens devraient l'utiliser aujourd'hui.

Le paysage des runtimes JavaScript est meilleur avec Bun. La concurrence rend Node.js meilleur aussi — Node.js 22+ est devenu significativement plus rapide, en partie en réponse à la pression de Bun. Tout le monde y gagne.

Articles similaires