node:bench

Benchmarking with node:bench

A micro-benchmark answers a narrow question: of two ways to write the same function, which is faster on this engine with this input? V8 86,723 needs thousands of calls before the optimizing compiler settles, garbage collection lands in whichever sample is unlucky, and a loop whose result is never used can be deleted outright. A benchmark that ignores all three prints a confident, meaningless number.

Node 26.9.0 added node:bench for exactly this. It is stability 1.0, early development, and requires --experimental-bench, so treat the API and its output as movable. Declare benchmarks with bench(name, options, fn), group them with suite(), and time the hot region with b.start() and b.end(operations).

A node:bench suite (Node 26, experimental)JavaScript
import { bench, suite } from 'node:bench';
suite('URL parsing', () => {
  const input = 'https://example.com/products?category=alpha&page=3';
  bench('new URL', { samples: 30, warmup: 5 }, (b) => {
    let total = 0;  b.start();
    for (let i = 0; i < 10_000; i++) total += new URL(input).searchParams.get('page').length;
    b.end(10_000);
    if (total !== 10_000) throw new Error('bad result');   // stop V8 deleting the loop
  });
});

Run it with node --experimental-bench --bench bench.mjs. The default spec reporter prints one row per benchmark with the sample count, mean rate in operations per second, a 95% confidence interval and the median, flagging a row noisy when the coefficient of variation exceeds 5%. The same discipline fits in fifteen portable lines, which is where the numbers below come from: this machine runs Node 25.8.0.

A portable harness with warmup, samples and a confidence intervalJavaScript
function measure(name, fn, ops = 200, samples = 30) {
  for (let i = 0; i < 5; i++) for (let j = 0; j < ops; j++) fn();     // warm up the compiler
  const rates = [];
  for (let s = 0; s < samples; s++) {
    const t0 = process.hrtime.bigint();
    for (let j = 0; j < ops; j++) fn();
    rates.push(ops / (Number(process.hrtime.bigint() - t0) / 1e9));
  }
  rates.sort((a, b) => a - b);
  const mean = rates.reduce((a, b) => a + b, 0) / samples;   // ops per second
  const sd = Math.sqrt(rates.reduce((a, r) => a + (r - mean) ** 2, 0) / (samples - 1));
  console.log(`${name.padEnd(30)} ${mean.toFixed(0).padStart(8)} ops/s  +/-` +
    `${(196 * sd / Math.sqrt(samples) / mean).toFixed(1)}%  median ${rates[15].toFixed(0)}`);
}
measure('structuredClone', () => structuredClone(doc));       // doc: ~25 KB of plain JSON
measure('JSON.parse(JSON.stringify())', () => JSON.parse(JSON.stringify(doc)));
measure('shallow spread', () => ({ ...doc, items: doc.items.slice() }));
Output
structuredClone                    3739 ops/s  +/-0.9%  median 3758
JSON.parse(JSON.stringify())       6416 ops/s  +/-2.0%  median 6492
shallow spread                  8162573 ops/s  +/-10.0%  median 8032129

For a 25 KB document of plain JSON values the structured clone algorithm is 1.7 times slower than a JSON round trip, contradicting the usual advice: structuredClone earns its keep on Map, Set, Date, typed arrays and cyclic references, none of which appear here. The third row copies two references instead of 200 objects, showing that the winning row often answers a different question; its ±10% marks it as noise-dominated. Run any suite twice — if two runs disagree by more than the confidence interval, the machine is lying to you.