10LOC
Bunintermediate

Benchmarking Bun's cold start against Node with performance.now()

Published April 30, 2027

const measureColdStart = (cmd: string[]) => {
  const start = performance.now();
  Bun.spawnSync(cmd);
  return performance.now() - start;
};

const runs = 5;
const bunTimes = Array.from({ length: runs }, () => measureColdStart(["bun", "-e", ""]));
const nodeTimes = Array.from({ length: runs }, () => measureColdStart(["node", "-e", ""]));
const avg = (times: number[]) => times.reduce((a, b) => a + b, 0) / times.length;

console.log(`bun:  ${avg(bunTimes).toFixed(1)}ms`);
console.log(`node: ${avg(nodeTimes).toFixed(1)}ms`);

What

Wrapping Bun.spawnSync(["bun"/"node", "-e", ""]) in performance.now() calls times how long each runtime takes to start a process, run an empty script, and exit — a direct measurement of cold-start overhead rather than a claim from a README.

Why it matters

Startup time is one of the more commonly repeated Bun-vs-Node numbers, but a claim from a blog post depends entirely on that author's machine, OS, and Bun/Node versions — worth measuring on whatever machine actually matters to you (a laptop, a specific CI runner, a container base image) rather than trusting a number measured somewhere else. performance.now() gives sub-millisecond, monotonic timestamps unaffected by wall-clock adjustments, and wrapping a subprocess spawn in it measures exactly the thing "cold start" usually means: time from process creation to a script finishing and exiting, for a runtime with nothing already warmed up.

How it works

  • measureColdStart spawns the given command synchronouslyBun.spawnSync blocks until the child exits, which is exactly what a cold-start measurement wants: no overlap with a previous run's timing.
  • -e "" runs an empty script in both runtimes — the timing captures pure startup/teardown overhead, not any work the script does.
  • Running runs iterations per runtime and averaging smooths out one-off noise (OS scheduling, disk cache state on the very first run) that a single measurement wouldn't.
  • avg is a plain reduce over the collected times — nothing benchmark-framework-specific, since the interesting part here is the spawn+timing pattern, not statistical rigor.

Gotchas

  • This measures wall-clock time in the parent process around a blocking spawn, not anything reported by the child — it necessarily includes OS process-creation overhead alongside runtime startup, which is usually what "cold start" means in practice anyway.
  • The first run of a loop is often slower than the rest (disk cache, OS scheduler warm-up) — either discard the first result or run enough iterations that it doesn't skew the average much.
  • This requires both bun and node to be installed and resolvable on PATH — there's no way to benchmark a runtime that isn't actually present to spawn.

Related

  • Bun.spawn (the async version) is the better choice for benchmarking something that overlaps with other work; spawnSync here is deliberate, since overlap is exactly what would corrupt a cold-start measurement.
  • proc.resourceUsage() after a spawn reports CPU time and memory separately from wall-clock time — useful alongside this if the question is resource cost rather than latency.