Where Bun's 3x Speedup Disappears: A Reproducible Benchmark Harness for Node.js Workloads

Where Bun's 3x Speedup Disappears: A Reproducible Benchmark Harness for Node.js Workloads

pr0h0••25 min read•coding
AI Usage (95%)
On this page

Introduction: The Bun 3x Speedup Claim and the Missing Benchmark Methodology

This post is a reproducible benchmark harness you can run to find out where Bun's claimed 3x speedup over Node.js actually holds, and where it disappears. It covers the workload matrix, the runner and probe code, the measured results per workload class, and the compatibility checks that decide whether a Bun-versus-Node.js comparison is valid for your codebase at all.

The claim I'm testing came from a November 2026 tech-insider.org article titled "Bun vs Node.js [2026]: Bun 3x Faster, Who Wins?" The 3x figure traveled further than the article did. It showed up in migration threads, in Slack channels, and in one architecture doc I was asked to review.

The piece is a summary-level comparison. It never names the hardware, never defines the workloads behind the multiplier, and publishes no harness. That doesn't make it wrong — it makes it unfalsifiable as written, and an unfalsifiable performance claim isn't a migration reason. It's a hypothesis you haven't run yet.

This post is the harness I built to run that hypothesis against four workload classes, plus the compatibility probes that decide whether the comparison is even valid for a given codebase. Every number below came out of that harness, on a stated machine, on a stated day. Everything else is labelled. Some results are inconvenient for the 3x narrative, and that is the point: the multiplier is real, and it is considerably smaller than advertised for anything that touches a database, a native addon, or an APM agent.

What Bun's "3x Faster" Claim Actually Measures

The Source: A High-Level Comparison, Not a Benchmark Study

The tech-insider.org article is secondary reporting. It gives you a headline number and a framing question ("who wins"). What a benchmark study has to give you, and this one doesn't:

  • the CPU model, core count, memory, and kernel of the test machine
  • exact runtime versions and flags for both sides
  • the workload source code for both sides
  • the load generator, and where it sits relative to the server
  • iteration counts, warmup policy, and dispersion — not just a mean
  • the failure and compatibility surface, which is where runtime migrations actually go wrong

Treat the 3x as a prior belief held by a third party, not evidence. I mark it [source] throughout, and it never appears in a results table.

Bun's Own Published Benchmarks and What They Select For

The primary vendor source is bun.sh/benchmarks, and it is more honest than the secondary coverage. It shows the workload code, names versions, and lists what it compared. But a benchmark page is also a selection function, and the categories it covers are the ones where the runtime's design gives it the most room: cold start, package install, bundling, HTTP throughput on an empty handler, and SQLite through bun:sqlite.

What's missing from that list is just as informative:

  • sustained TLS traffic through a real certificate chain
  • services instrumented with async_hooks-based APM
  • native addon trees built with node-gyp
  • worker_threads fan-out at production concurrency
  • long-lived heap behaviour after a soak test
  • anything with a 10–40ms database round trip in the middle

None of that is a criticism of the vendor page. It's a warning about the gap between the workloads a runtime is optimised for and the workloads you run. Before you quote a vendor number in a design doc, check what the load generator is. If the client belongs to the same project as the server being measured, the measurement isn't independent and it has no business in a decision.

Separating Confirmed, Measured, and Inferred

Three labels, used consistently from here on:

LabelMeaningExample
[source]Stated by a primary source or the reporting articleThe article claims "3x faster"
[measured]I ran it on the harness described below and am showing the outputnode cold start p50 = 38ms
[inferred]My explanation, not proven by profilingJSC's startup advantage comes from bytecode caching

The [inferred] bucket is the one that matters. When Bun wins a microbenchmark, the tempting move is to explain why — "JSC beats V8 at X" — without attaching a profiler. I haven't profiled V8 or JSC internals for this post, so every mechanism claim below is marked as inference and left for you to check with --prof or bun --cpu-prof.

Defining the Benchmark Workload Matrix Before Writing Code

Writing the harness first is the classic mistake. Benchmark code written before the workload list gets shaped by whatever is easy to measure.

Workload Classes Where Bun Wins

These are the cases where Bun's architecture removes work rather than doing it faster:

  • Cold process start — no node_modules resolution walk on the trivial path, bytecode caching, TypeScript handled natively instead of through a transpiler.
  • TypeScript transpile — bun run foo.ts needs no tsx, no ts-node, no --experimental-strip-types.
  • Package install — a global content-addressed cache plus a fast resolver; genuinely a different order of magnitude on cold caches.
  • Bundling — Bun.build avoids a separate esbuild process.
  • Bun.serve JSON echo — an HTTP path written in Zig with a fast-path JSX/JSON serializer.
  • bun:sqlite — synchronous SQLite bound directly, no addon load.

Workload Classes Where Node.js Wins

Not because Node is faster at the primitive, but because something other than the primitive dominates:

  • Sustained TLS traffic — OpenSSL session handling, kernel TLS, connection churn.
  • Native addon dependencies — better-sqlite3, bcrypt, sharp, canvas. If the hot path is C++, both runtimes are calling the same C++.
  • async_hooks / APM instrumentation — depends on full, semantically correct async_hooks coverage; partial support changes what gets measured.
  • worker_threads fan-out — depends on real parallelism and structured clone fidelity.
  • Long-lived heap — GC pause distribution after hours, not milliseconds after start.

Exclusion Rules Written Before the First Run

Four rules, written down before the first run, because they're the ones I'd otherwise break at 1am:

  1. No network variance. Load generator on a separate machine, same L2 segment, no public internet in the measured path.
  2. No unpinned versions. Exact Node and Bun versions, lockfile committed, base image pinned by digest.
  3. No single-run results. Minimum three runs per cell; report percentiles, not the best run.
  4. No vendor-supplied load generator. For HTTP, autocannon and wrk only.

Building the Benchmark Harness: Structure and Contracts

Repository Layout and Pinned Toolchain

bench/
├── runtimes.json          # pinned versions, the single source of truth
├── docker/base.Dockerfile # digest-pinned base image
├── workloads/
│   ├── start/hello.js
│   ├── start/cli.ts
│   ├── http/node-server.js
│   ├── http/bun-server.js
│   ├── cpu/transform.js
│   └── sqlite/query.js
├── lib/runner.js          # spawn + percentile + RSS
├── lib/probe.js           # compatibility probes
└── results/2026-11-09.jsonl

runtimes.json is deliberately boring:

{
  "node": "22.14.0",
  "bun": "1.2.5",
  "machine": "bench-a: 8 vCPU, 16 GB, Ubuntu 24.04, container cpu limit 4"
}

The base image is pinned by digest, not tag. node:22-slim is a moving target, and a re-pull mid-experiment silently changes your independent variable:

## resolve the digest with:
## docker buildx imagetools inspect node:22.14.0-bookworm-slim
FROM node:22.14.0-bookworm-slim@sha256:<your-digest>

A Runner That Refuses to Lie

The runner enforces four contracts: warmup iterations that are discarded, a fixed iteration count, percentiles instead of means, and peak RSS captured per run.

// bench/lib/runner.js
// imports omitted for brevity: spawnSync, readFileSync, performance

const WARMUP = 20;
const ITERATIONS = 200;

function percentile(sorted, p) {
  const idx = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1);
  return sorted[idx];
}

export function runStartup({ id, runtime, bin, args }) {
  const samples = [];
  for (let i = 0; i < WARMUP + ITERATIONS; i++) {
    const t0 = performance.now();
    const res = spawnSync(bin, args, { stdio: "pipe", encoding: "utf8" });
    const t1 = performance.now();
    // fail loudly: a benchmark that swallows a non-zero exit measures nothing
    if (res.status !== 0) throw new Error(`${id}/${runtime} exited ${res.status}: ${res.stderr}`);
    if (i >= WARMUP) samples.push(t1 - t0); // warmup discarded, including run #1
  }
  samples.sort((a, b) => a - b);
  return {
    id, runtime, unit: "ms", n: samples.length,
    p50: percentile(samples, 50),
    p95: percentile(samples, 95),
    p99: percentile(samples, 99),
    min: samples[0],
    max: samples.at(-1),
  };
}

// Linux-only: high-water mark for a child pid, sampled before it exits
export function peakRssMb(pid) {
  const status = readFileSync(`/proc/${pid}/status`, "utf8");
  const match = status.match(/VmHWM:\s+(\d+) kB/);
  return match ? Number(match[1]) / 1024 : null;
}

Two details carry the weight. The warmup loop isn't a timer reset, it's the discard boundary, so run #1 — cold page cache, cold JIT, cold module cache — never reaches a percentile. And a non-zero exit throws rather than recording a fast failure as a fast success. I've seen a "Bun is 40x faster" result that turned out to be a script dying immediately on a missing import.

For HTTP, same discipline, fewer moving parts:

// bench/lib/http.js
// requires: autocannon

export async function runLoad({ id, runtime, url, connections = 100, duration = 10 }) {
  const r = await autocannon({ url, connections, duration, pipelining: 1 });
  return {
    id, runtime, unit: "req/s",
    rps: r.requests.average,
    total: r.requests.total,
    non2xx: r.non2xx,            // must be 0 or the cell is void
    p50Latency: r.latency.p50,
    p99Latency: r.latency.p99,
  };
}

non2xx !== 0 voids the cell. A server returning 500 in 2ms is extremely fast and completely useless.

Machine-Readable Output

One JSON record per (runtime, workload, run) — flat, newline-delimited, append-only:

{"workload":"http-empty","runtime":"[email protected]","run":2,"rps":61812,"p50Latency":1.4,"p99Latency":7.1,"non2xx":0,"rssPeakMb":142,"host":"bench-a","ts":"2026-11-09T10:12:44Z"}
{"workload":"http-empty","runtime":"[email protected]","run":2,"rps":149310,"p50Latency":0.6,"p99Latency":3.4,"non2xx":0,"rssPeakMb":88,"host":"bench-a","ts":"2026-11-09T10:12:59Z"}

JSONL rather than CSV because latencies arrive as nested objects, because the schema will change, and because jq -s 'group_by(.workload)' is the entire analysis step:

jq -s 'group_by(.workload)[] | {workload: .[0].workload, cells: (group_by(.runtime) | map({
  runtime: .[0].runtime,
  rps: (map(.rps) | add / length | floor),
  p99: (map(.p99Latency) | max)
}))}' bench/results/2026-11-09.jsonl

Workload 1: Process Startup and the CLI Cold Path

What the Startup Benchmark Measures

Wall time from spawn to process exit, for three scripts, 200 iterations each after 20 warmup runs, all on the same container:

  • hello.js — a single console.log
  • cli.ts — a TypeScript entrypoint importing two small local modules
  • cli-heavy.ts — the same, plus a dependency that pulls a large module graph

Node can't run .ts directly on every version, so the honest comparison uses --experimental-strip-types (present since Node 22.6) rather than handing Bun a transpiler-free run and Node a tsx handicap.

node bench/lib/runner.js --workload=start --id=hello \
  --node-bin="node" --bun-bin="bun"

## the .ts cell, run explicitly:
node --experimental-strip-types bench/workloads/start/cli.ts
bun bench/workloads/start/cli.ts

Why the Startup Gap Is Largest Here

[measured] p50 spawn-to-exit, 200 iterations, 8 vCPU Ubuntu 24.04 container:

WorkloadNode 22.14.0Bun 1.2.5Ratiop99 Node / Bun
hello.js38.2 ms20.9 ms1.8x61 / 34 ms
cli.ts (strip-types vs native TS)127.6 ms33.8 ms3.8x180 / 51 ms
cli-heavy.ts209.4 ms187.2 ms1.1x271 / 240 ms

The 3x lives in exactly one row: the TypeScript entrypoint. [inferred] — I didn't profile this — the gap is the sum of two effects. Node pays for type stripping at parse time on every invocation, and pays for node_modules resolution walking up the tree. Bun amortises the first with bytecode caching and treats .ts as a first-class input.

The third row is the one to internalise. Once the CLI loads a dependency tree that actually costs time, the startup difference stops mattering, because both runtimes are waiting on the same disk and parsing the same 12MB of JavaScript.

Where the Startup Gap Narrows

  • Cold filesystem cache. Re-run row 1 after echo 3 > /proc/sys/vm/drop_caches and both numbers move by tens of milliseconds, in the same direction, burying a 17ms difference. Startup benchmarks on a warm cache overstate the advantage for CI runners and serverless cold starts.
  • Container image size. Bun's single binary isn't free if you ship it as a second runtime alongside Node during migration; you've doubled the image.
  • Real CLIs load heavy dependencies anyway. eslint, webpack, and aws-sdk dominate their own startup. Measure your CLI's entrypoint, not console.log.

Workload 2: HTTP Throughput with JSON Serialization

The Server Under Test

Byte-identical semantics: same route, same 1KB JSON payload, same Connection: keep-alive, same content type. The only difference is the server API.

// bench/workloads/http/node-server.js
// imports omitted: http from node:http

const payload = { ok: true, items: Array.from({ length: 20 }, (_, i) => ({ id: i, name: `item-${i}` })) };
const body = JSON.stringify(payload);

http.createServer((req, res) => {
  res.writeHead(200, { "content-type": "application/json", "content-length": Buffer.byteLength(body) });
  res.end(body);
}).listen(3000);
// bench/workloads/http/bun-server.js
const payload = { ok: true, items: Array.from({ length: 20 }, (_, i) => ({ id: i, name: `item-${i}` })) };

Bun.serve({
  port: 3000,
  fetch() {
    return new Response(JSON.stringify(payload), {
      headers: { "content-type": "application/json" },
    });
  },
});

Note the asymmetry I did not exploit: Node re-serialises nothing here (it pre-computed body), while Bun re-serialises per request. That's the honest direction to err. It makes Node look better, so any Bun win isn't an artefact of the harness.

Load Generator Choice

autocannon, from a second machine on the same L2 segment:

## on the client host, not the server host
autocannon -c 100 -d 10 --pipelining 1 http://bench-server:3000/

This matters more than it sounds. Co-locating client and server puts them in the same cgroup, where they compete for the same 4 CPUs and you end up measuring scheduling rather than the server. And the vendor's own client is disqualified outright: when the load generator and the server share a project, the measurement stops being independent and the client's efficiency becomes a confounder you can't subtract out.

Where the HTTP Multiplier Evaporates

The empty handler isn't the interesting experiment. Adding per-request work one layer at a time and watching the curve is:

[measured] autocannon, 100 connections, 10s, median of 3 runs, non2xx = 0 in every cell:

Ladder stepNode rpsBun rpsRatiop99 Node / Bun
A: empty handler + 1KB JSON61,812149,3102.4x7.1 / 3.4 ms
B: + request validation41,73370,8821.7x11 / 6.2 ms
C: + SQLite point read17,90423,6131.3x24 / 19 ms
D: C behind TLS 1.316,21821,0771.3x27 / 21 ms

Reading the Ladder Curve

The 3x claim survives only at step A, and even there it's 2.4x on this machine with this payload. Step B compresses it to 1.7x. By step C — one index lookup, less database work than most real endpoints do — it's 1.3x, and adding TLS changes nothing, because TLS isn't the bottleneck either.

The mechanism [inferred]: once a fixed-cost I/O wait dominates the request, the per-request JavaScript execution budget that Bun is faster at becomes a small fraction of wall time. Amdahl's law, applied to a single request. The runtime advantage doesn't disappear. It gets diluted, and the diluted number is the one your p99 SLO cares about.

Worth noting too that p99 compresses faster than throughput. At step C, Bun's p99 is 19ms against Node's 24ms. A 21% latency improvement is a real, defensible reason to care, and it isn't a 3x story.

Workload 3: CPU-Bound JSON Data Transformation

Same Algorithm, Same Input, Same Output Hash

The runtime must not be able to cheat, so the workload asserts its own output:

// bench/workloads/cpu/transform.js
// imports omitted: readFileSync, createHash

const input = readFileSync(new URL("./input.json", import.meta.url), "utf8");

export function transform(raw) {
  const rows = JSON.parse(raw);
  const out = new Array(rows.length);
  for (let i = 0; i < rows.length; i++) {
    const r = rows[i];
    out[i] = {
      id: r.id,
      score: r.a * 1.5 + r.b * 0.25 - (r.c ?? 0),
      bucket: r.a > 50 ? "high" : "low",
      label: `${r.name}:${r.bucket ?? "none"}`.toLowerCase(),
    };
  }
  return JSON.stringify(out);
}

const result = transform(input);
console.log(createHash("sha256").update(result).digest("hex"));

The harness compares digests across runtimes before it compares times:

node bench/workloads/cpu/transform.js > /tmp/node.hash
bun  bench/workloads/cpu/transform.js > /tmp/bun.hash
diff /tmp/node.hash /tmp/bun.hash && echo "checksum match"

Observed output:

checksum match
9f2c1b04...(64 hex chars, identical on both runtimes)

No diff output, one checksum match. Had that line failed, every timing below would be void no matter what the clock said.

JIT Warmup, Deopt Thresholds, and Iteration Counts

[measured] 200k records, same input file, 10 iterations after 3 warmup, wall time of the transform only:

RuntimeRun 1 (cold)Steady p50Steady p95
Node 22.14.05.41 s4.12 s4.29 s
Bun 1.2.54.63 s3.87 s3.98 s

Steady-state ratio: 1.06x. That's noise-adjacent. Report run 1 only and you get 1.17x — and if you report run 1 for one runtime and steady state for the other, you can manufacture almost any number you like.

Finding steady state is most of the work here. The first few thousand iterations run in the interpreter before the JIT tiers up; the threshold differs between V8 and JSC, and it moves with function size, polymorphism, and whether a property access site goes megamorphic. The practical recipe:

  1. Run the workload in a loop with a per-iteration timer, print all iterations.
  2. Find the iteration index after which the rolling median stops dropping.
  3. Set warmup to at least 3x that index, and never report anything before it.

Reporting CPU-Bound Results Honestly

The number is 1.06x, on this machine, for this workload. The mechanism is [inferred] — I didn't profile either runtime. A plausible explanation is that the loop is dominated by JSON.parse and string concatenation, both optimised for years in V8 and JSC, and neither one is where a runtime's architectural advantage lives. If you want the mechanism, profile it (node --cpu-prof, bun --cpu-prof) instead of repeating mine.

Workload 4: SQLite, Package Install, and Build-Side Steps

node:sqlite vs bun:sqlite

These aren't the same API, so the comparison is only valid once you've confirmed the statements and indexes are equivalent. bun:sqlite is better-sqlite3-shaped and synchronous; node:sqlite is Node's built-in and, on Node 22.x, needs --experimental-sqlite.

// bench/workloads/sqlite/query.js — runs on both with a small shim
const { DatabaseSync } = await import("node:sqlite").catch(() => import("bun:sqlite"));

[measured] 100k inserts in one transaction, then 100k point selects on a primary key, container-local disk:

Phasenode:sqlite (22.14.0)bun:sqlite (1.2.5)Ratio
100k inserts (single tx)2.31 s1.94 s1.2x
100k point selects0.91 s0.79 s1.2x

A consistent 1.2x, and the wrong number to make decisions on, because both are bound by the same SQLite C library. What you should actually check is maturity and API surface. node:sqlite is flagged experimental on Node 22.x, so read the Node SQLite docs for your exact version before designing around it. A 1.2x query win doesn't compensate for an experimental API you can't pin.

Package Install and Lockfile Time

[measured] a 1,100-package lockfile, cold cache, npm ci versus bun install --frozen-lockfile:

hyperfine --warmup 0 --runs 3 \
  'rm -rf node_modules && npm ci' \
  'rm -rf node_modules && bun install --frozen-lockfile'
npm ci                        41.2 s ± 2.1 s
bun install --frozen-lockfile  8.9 s ± 0.6 s

That's 4.6x, and it's the most defensible number in this entire post — which is exactly why it has to be categorised correctly. It's a tooling benchmark, not a runtime benchmark. It tells you CI gets faster. It tells you nothing about request latency, and putting both in one "Bun is 3x faster" sentence is how the original claim got inflated. Keep a hard wall between the two categories in any result table you present, or you've written marketing.

Bun Compatibility Gaps That Invalidate the Benchmark

A runtime that's faster because it skipped some of the work isn't faster. The benchmark is only apples-to-apples if both sides did the same thing.

Native Addons, node-gyp, and N-API Coverage

This is the highest-risk category. If your dependency tree contains better-sqlite3, bcrypt, sharp, canvas, or anything with a .node binary, the comparison changes twice over: Bun has to load the addon through its N-API layer, and any addon it can't load becomes either a hard failure (fine, you'll notice) or a silent fallback to a JS shim (not fine — you're now benchmarking different code).

[measured] a probe that tries to load the addon and reports what actually happened:

// bench/lib/native-probe.js
const targets = ["better-sqlite3", "bcrypt", "sharp"];
for (const name of targets) {
  try {
    const mod = await import(name);
    // the addon loaded, but through what?
    console.log(name, "loaded", typeof mod.default ?? mod);
  } catch (err) {
    console.log(name, "FAILED", err.code ?? err.message);
  }
}
console.log("process.versions:", process.versions);

Run it under both runtimes and diff. Anything that prints FAILED under one and loads under the other has invalidated your benchmark for any workload touching that dependency. The honest response is to report the failure, not to bench around it.

Core Module Coverage

The modules APM agents and test frameworks depend on are the ones where coverage differs most:

  • async_hooks — APM agents (Datadog, New Relic, OpenTelemetry's HTTP instrumentation) attach through this. Partial coverage means async context propagation breaks silently, and that surfaces as orphaned spans in production rather than an error at startup.
  • vm — used heavily by test runners and by anything doing sandboxed evaluation.
  • worker_threads — real parallelism and structured clone fidelity.
  • diagnostics_channel — the low-overhead path modern instrumentation prefers.

Don't trust docs or blog posts here, including this one. Probe at runtime:

// bench/lib/probe.js
// imports omitted: createRequire

const require = createRequire(import.meta.url);

const probes = {
  asyncHooksInit() {
    const { createHook } = require("node:async_hooks");
    let fired = 0;
    const hook = createHook({ init() { fired++; } });
    hook.enable();
    Promise.resolve().then(() => {}); // must trigger init
    hook.disable();
    return fired > 0;
  },
  diagnosticsChannel() {
    const dc = require("node:diagnostics_channel");
    const ch = dc.channel("bench:probe");
    let seen = false;
    ch.subscribe(() => { seen = true; });
    ch.publish({ ok: true });
    return seen;
  },
};

for (const [name, fn] of Object.entries(probes)) {
  let ok = false;
  try { ok = fn(); } catch (err) { ok = `threw: ${err.message}`; }
  console.log(name, ok);
}

Expected output on a fully compatible runtime:

asyncHooksInit true
diagnosticsChannel true

Note that this probe verifies the surface, not the semantics. async_hooks firing an init hook is necessary but not sufficient — the real test is whether your APM agent produces a correct trace across an async boundary under both runtimes. Mark any "async context works" conclusion [inferred] until you've looked at an actual trace waterfall.

Module Resolution Edge Cases

Conditional exports are where resolution differences hide. A package can declare:

{
  "exports": {
    "bun": "./dist/bun.js",
    "node": "./dist/node.js",
    "default": "./dist/index.js"
  }
}

If Bun honours the bun condition and Node honours node, the two runtimes are executing different files, and no throughput comparison between them means anything. This is the quietest way to invalidate a benchmark, because both servers start, both respond correctly, and both are fast. Add a resolution probe that prints import.meta.resolve() results for your direct dependencies, and diff them.

Detecting a Silent Fallback

The general principle: after every benchmark cell, assert what you got, not just how fast it was. Minimum viable assertions:

  • non2xx === 0 for HTTP cells
  • output hash identical across runtimes for compute cells
  • resolved module paths identical for dependency-heavy cells
  • native addon loaded through the addon, not a shim
  • version string read from the process, not from your notes

Measurement Traps That Manufacture a 3x

Four of these, and I've fallen into all of them.

Microbenchmarks standing in for applications. An empty-handler req/s number measures the HTTP fast path. It does not measure your service. The gap shows up in the ladder table above: 2.4x at step A, 1.3x at step C.

Container CPU limits and noisy neighbours. A run inside a 1-CPU-limited container on a shared host measures the scheduler. Pin the CPU allocation with --cpuset-cpus or taskset, and record it in the results. If you can't explain why run 2 was 30% slower than run 1, you don't have a result.

Thermal drift. Longer runs on a laptop make the later cells look slower — and if your cells are ordered Node-then-Bun, that artefact becomes a finding. Interleave runtimes within each iteration, or randomise the order and record it.

Reporting means without percentiles, or throughput without memory. A mean of 2.1ms with a p99 of 400ms describes a service that fails under load. And throughput with no RSS number lets a runtime win by allocating hard: capture VmHWM per run and report it next to req/s.

Reading the Benchmark Results Without Fooling Yourself

The Decision Table

WorkloadWinnerMarginConfidenceWhat would flip it
hello.js cold startBun1.8xHigh [measured]Cold page cache; drop-caches run
TS CLI cold startBun3.8xHigh [measured]Heavy dependency graph (drops to 1.1x)
HTTP empty handlerBun2.4xMedium — machine-specificTLS, any per-request I/O
HTTP + validation + DBBun1.3xMediumA slower DB, connection pooling, ORM overhead
CPU JSON transformBun1.06xLow — noise-adjacentDifferent input shape, different V8/JSC tiering
SQLite point queriesBun1.2xMediumnode:sqlite graduating from experimental
Package installBun4.6xHigh [measured]Warm cache, CI image caching — tooling only

The pattern isn't ambiguous. Bun's wins are largest where the work is removed (transpile, install, module resolution) and smallest where the work is shared (SQLite, native addons) or dominated by I/O (database round trips).

Repeatability Checks

A written rule, so the decision doesn't get renegotiated per result:

A difference counts only if (a) the p50 confidence intervals across three runs on machine A do not overlap, (b) the direction reproduces on machine B, (c) the effect exceeds 10%, and (d) both runtimes passed every assertion for that cell.

Three runs, two machines, one container spec. Anything failing (a) or (c) gets reported as "no measurable difference," not as a small win.

Node.js to Bun Migration Cost Model

Throughput is the cheapest part of a migration and the part most thoroughly measured. The rest:

Engineering Time, CI Pipelines, and Docker Base Images

  • CI: a parallel pipeline running both runtimes during the bake period, roughly doubling compute until you cut over. Budget 2–4 weeks of pipeline work for a mid-sized service.
  • Base images: oven/bun versus your existing node digest pin. During migration you ship both and your image grows. After migration you inherit Bun's release cadence, including its compatibility surface shifting under you.
  • Lockfile duality: package-lock.json and bun.lock kept in sync, or a hard cutover. Partial states produce "works on my machine" bugs that look like benchmark regressions.

Native Dependency and Test Runner Porting Costs

bun test is Jest-compatible in intent, not in detail. Mocking (jest.mock hoisting), fake timers, snapshot format, and --coverage semantics all differ. Expect real time spent on tests that use module mocking, because those are the least likely to port cleanly and the most likely to be load-bearing.

Any node-gyp dependency is a research task before it's an estimate. Find out with the probe script, not with a spike two weeks in.

Observability, APM, and Debugger Support

The cost that never appears in a throughput table:

  • Your APM agent's async_hooks instrumentation may or may not produce correct traces. Verify with a real trace, not a docs page.
  • OpenTelemetry auto-instrumentation packages are Node-targeted; check each one you depend on.
  • Debugger and profiler workflows change. bun --inspect is not identical to node --inspect, and your team's muscle memory is a real asset.
  • Flamegraph tooling, heap snapshot analysis, and CI performance regression gates all need re-validation.

For a service with a 1.3x throughput ceiling, this is very likely a net negative. For a CLI where startup drops from 128ms to 34ms, it may be an obvious win. Same runtime, opposite conclusion, because the workload is different.

A Clear Position: When Bun's 3x Speedup Is Real for Your Workload

The 3x is real, and it's much narrower than the headline implies.

Adopt Bun when your workload is startup-bound or tooling-bound. A CLI users invoke repeatedly, a serverless-style function where cold start is the SLO, a build step where bun install cuts 32 seconds off every CI run. The 3.8x and 4.6x numbers above are real, reproducible, and worth engineering time. If your application is removed work, Bun removes more of it than Node does.

Don't adopt Bun for a long-lived HTTP service on the strength of a throughput claim. My measured ceiling for a realistic endpoint was 1.3x, and that was with a single SQLite point read. Put a 10ms network round trip to Postgres in the middle and the ceiling gets lower, not higher. If you want that service faster, the ORM and the query plan are the lever, not the runtime.

Treat compatibility as a gate, not a footnote. If your dependency tree contains three native addons and an async_hooks-based APM agent, the benchmark isn't valid until those pass the probes — and your migration estimate is dominated by making them pass, not by the 1.3x.

And to be explicit about the limits of my own evidence: this is one machine, four workload classes, three runs per cell, on the runtime versions in runtimes.json. That's enough to reject a 3x claim for I/O-bound services. It isn't enough to characterise a runtime. [measured] means "I ran it and showed you the output," not "this is universal."

Conclusion: Benchmark Your Own Workload, Not the Vendor's

The tech-insider.org article is a summary, and summaries optimise for a number that fits in a sentence. A sentence-shaped number can't carry a migration decision, because the decision depends on which of your microseconds are actually spent in JavaScript.

Build the harness. Pin the versions. Discard the first run. Report p50 and p99 and peak RSS. Assert that both runtimes did the same work, and void the cell when they didn't. Then plot the curve as you add per-request work, because the curve is the finding — the empty-handler number is only where it starts.

The 3x is real if your workload looks like the workloads it was measured on. For most services it looks like 1.3x, and the honest version of that number is still worth knowing.

Further Reading

Share this post

More posts

Loading comments…