Measure Before You Optimise — Tools and Pitfalls for Real JS Performance Work
Benchmarking in Node.js
Learn how to measure JavaScript performance reliably using console.time, performance.now, node --prof, and the mitata library — including warmup requirements and GC noise pitfalls.
What you'll learn
- Use console.time and performance.now for quick timing measurements
- Explain why JIT warmup affects benchmark results and how to compensate
- Profile a Node.js script with node --prof and interpret the output
Big-O tells you the shape of growth. A benchmark tells you the actual number. Optimising without measuring is guessing. This lesson covers the practical toolkit for measuring JavaScript performance in Node.js.
console.time / console.timeEnd
The simplest approach. Use it for coarse, one-off timing during development.
const data = Array.from({ length: 1_000_000 }, (_, i) => i);
console.time("linear scan");
const found = data.includes(999_999);
console.timeEnd("linear scan");
// linear scan: 2.341ms Resolution varies — console.time is not suitable for sub-millisecond
measurements or for comparing two implementations rigorously.
performance.now
performance.now() (from Node’s perf_hooks, or globally in Node 16+)
returns a high-resolution timestamp in milliseconds with microsecond precision.
import { performance } from "node:perf_hooks";
function bench(label, fn, iterations = 10_000) {
// Warmup — let V8 JIT-compile the function
for (let i = 0; i < 100; i++) fn();
const start = performance.now();
for (let i = 0; i < iterations; i++) fn();
const end = performance.now();
const total = end - start;
console.log(`${label}: ${(total / iterations).toFixed(4)} ms/op`);
}
bench("array includes", () => [1, 2, 3, 4, 5].includes(3));
bench("set has", () => new Set([1, 2, 3, 4, 5]).has(3)); Why Warmup Matters
V8’s JIT compiler has multiple tiers. When a function first runs, V8 interprets it. After it runs enough times (typically a few hundred), V8 compiles it to optimised machine code. Measuring only cold runs captures interpreter performance, not the code users actually experience.
Always run several warmup iterations before starting your timer.
function sumArray(arr) {
let total = 0;
for (let i = 0; i < arr.length; i++) total += arr[i];
return total;
}
const arr = new Array(1000).fill(1);
// Without warmup — measures interpreter + early compile overhead
const t1 = performance.now();
sumArray(arr);
console.log(performance.now() - t1, "ms (cold)");
// With warmup — measures steady-state optimised code
for (let i = 0; i < 500; i++) sumArray(arr); // warmup
const t2 = performance.now();
for (let i = 0; i < 10_000; i++) sumArray(arr);
console.log((performance.now() - t2) / 10_000, "ms/op (warm)"); GC Noise
Node’s garbage collector can pause execution mid-benchmark. For short benchmarks this creates statistical noise — one run looks 10× slower because GC fired. Mitigation strategies:
- Run enough iterations to dwarf GC pauses (thousands, not tens)
- Use
--expose-gcand callglobal.gc()before each trial to force a collection at a known point - Report median or p95 latency, not just average
// node --expose-gc bench.js
global.gc(); // clear the deck before measuring
const start = performance.now();
for (let i = 0; i < 100_000; i++) doWork();
console.log(performance.now() - start, "ms"); node —prof
For deeper profiling, Node ships a built-in V8 profiler:
// profile.js
const data = Array.from({ length: 100_000 }, (_, i) => i);
function findSquares(arr) {
return arr.filter(n => {
const s = Math.sqrt(n);
return Number.isInteger(s);
});
}
for (let i = 0; i < 1000; i++) findSquares(data); Run with node --prof profile.js. This produces an isolate-*.log file.
Process it with node --prof-process isolate-*.log | head -40 to see the
top CPU consumers. Look for your function names in the “Bottom up (heavy)
profile” section.
mitata — Statistical Microbenchmarking
For rigorous comparisons, use a benchmarking library. mitata is modern,
fast, and works in Node, Deno, and Bun.
// npm install mitata
import { bench, run } from "mitata";
const arr = Array.from({ length: 10_000 }, (_, i) => i);
const set = new Set(arr);
bench("Array.includes", () => arr.includes(9999));
bench("Set.has", () => set.has(9999));
await run();
// clk: ~3.20 GHz
// Array.includes 12.45 µs/iter ±0.8%
// Set.has 0.42 ns/iter ±1.1% mitata handles warmup, statistical outlier removal, and reports mean, p50, p75, p99 — giving you confidence that the difference is real, not noise.
Common Pitfalls
| Pitfall | Symptom | Fix |
|---|---|---|
| No warmup | Cold run 50× slower than expected | Add 100-500 warmup iterations |
| Dead code elimination | Loop completes in 0 ns | Consume the return value |
| GC during timing | Outlier spikes | Run more iterations, force GC before |
| Input too small | Both algorithms appear O(1) | Use realistic production-sized data |
| Single timing sample | High variance between runs | Report median over many runs |
Up Next
With analysis tools in hand, the next stop is the first concrete data structure: arrays in depth — how they are laid out, what operations cost, and when to reach for something else.
Arrays In Depth →