Benchmarking in Node.js

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.

6 min read Level 2/5 #dsa#benchmarking#performance
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-gc and call global.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

PitfallSymptomFix
No warmupCold run 50× slower than expectedAdd 100-500 warmup iterations
Dead code eliminationLoop completes in 0 nsConsume the return value
GC during timingOutlier spikesRun more iterations, force GC before
Input too smallBoth algorithms appear O(1)Use realistic production-sized data
Single timing sampleHigh variance between runsReport 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 →