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).
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.
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() }));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.