AI InterviewSearch…Search problems, topics, or pages…Ctrl KGet started
Skip to content

15 Performance and memory / Lesson 2 of 5

Measure first — benchmarks that lie, profiles that do not, and reading the numbers

13 min · +10 XP · 2 exercises · 5 questions

Read the text13 min
Exercises0/2
Quiz5 q

Quick answer

How do you benchmark JavaScript code correctly?

Time with performance.now(), never Date.now(), and warm the code up first so the JIT has optimised it before you measure. Feed inputs built at run time, consume every result so the optimiser cannot delete the work, repeat many times, interleave the alternatives, and compare medians and p95 values rather than single runs or means. Libraries such as tinybench and mitata automate this.

Almost every performance change made without measuring makes the code worse in some way — less readable, more fragile, or actually slower — because intuition about JIT-compiled code is unreliable and the bottleneck is rarely where it feels. The discipline is simple to state: profile to find the hot spot, benchmark to compare alternatives, measure in production to confirm. Each step has traps: benchmarks that time the wrong thing (JIT warm-up, dead-code elimination, GC pauses), profiles read at the wrong level, and summary numbers (averages) that hide the users who suffer. This lesson covers the tools — performance.now, console.time, --cpu-prof, DevTools, flame charts — and the statistics you need to trust a result: warm-up, repetitions, medians and percentiles, variance, and what "significant" means.

Timing correctly

const t0 = performance.now();            // monotonic, sub-millisecond; global in Node 16+ and browsers
work();
const ms = performance.now() - t0;
process.hrtime.bigint();                 // nanoseconds, for very short spans
console.time("parse"); parse(); console.timeEnd("parse");   // quick and dirty

Date.now() is wall-clock and coarse; never use it for durations. performance.mark/measure label spans that DevTools and PerformanceObserver can display. In browsers, performance.now() is coarsened (to ~100 µs, or 5 µs with cross-origin isolation) for security — fine for anything you can see, useless for nanosecond claims.

Why naive benchmarks lie

const t0 = performance.now();
for (let i = 0; i < 1e6; i++) add(i, 1);       // what did this measure?
  1. JIT warm-up: the first thousands of iterations run interpreted, then get optimised mid-loop — the total mixes tiers. Warm the code before timing, then time a separate run.
  2. Dead-code elimination: add(i, 1)'s result is unused; the optimiser may remove the call entirely and you time an empty loop. Consume results (accumulate and print them).
  3. Constant folding and inlining: a benchmark with literal inputs lets the compiler precompute answers a real workload would not. Feed data from outside the function (an array built at runtime).
  4. GC pauses: an allocation-heavy loop pays for collections at unpredictable moments — a single run's time is noise. Repeat and look at the distribution.
  5. Micro-benchmarks measure micro-effects: a 2× difference on a 5 ns operation is irrelevant next to one database call. Benchmark the operation at the granularity that matters.
  6. Machine noise: turbo boost, other processes, thermal throttling. Run several times, interleave the alternatives, and compare distributions, not single numbers.

Tools that handle this for you: Benchmark.js/tinybench/mitata (warm-up, many samples, statistics), Node's --allow-natives-syntax with %OptimizeFunctionOnNextCall for controlled experiments, and deno bench/bun bench. Write the harness yourself once to see what they do; then use one.

Reading a distribution

Report the median (p50) and a high percentile (p95/p99), not the mean — a few slow runs (GC, page faults) drag the mean up and hide the typical case; a few fast ones hide the tail that users notice. Report the spread (standard deviation, or min/max, or an interquartile range). Two alternatives are distinguishable only when their distributions barely overlap: a 3% difference with 10% run-to-run variance is noise. Discard the first samples (warm-up), use enough repetitions that the median stabilises, and be suspicious of any result you cannot reproduce twice.

Profiling: find the hot spot

Benchmarks compare alternatives you already suspect. A profiler tells you where time actually goes:

  • Node: node --cpu-prof app.js writes a .cpuprofile to open in Chrome DevTools (Performance panel → load) or speedscope; --prof + --prof-process gives a text tick summary; --inspect attaches DevTools live. clinic flame builds flame graphs from a running process.
  • Browser: DevTools → Performance → record → the flame chart (time on the x-axis, call depth on the y-axis: a wide bar is where time goes; a tall narrow stack is recursion) plus the bottom-up table (self time per function). Long tasks over 50 ms are the input-blocking culprits.
  • Sampling (what these do) runs the program at full speed and samples the stack every ~1 ms — cheap and honest; instrumentation (timers around every function) is exact but distorts.

Read a profile top-down for structure and bottom-up for self time. The usual finding: one or two functions account for most of the time, and they are not the ones you guessed. Optimise those; leave the rest alone.

Complexity before constants

Before any micro-work, check the algorithm: a linear scan inside a loop (includes per item — O(n²)), a repeated Object.keys in a hot loop, sorting inside a comparator, re-rendering everything for one change, an N+1 query. Big-O differences swamp every JIT effect: replacing an O(n²) with O(n) on 10 000 items is a 10 000× change; the fastest possible O(n²) is still O(n²). Estimate complexity by measuring operation counts at two or three input sizes — if doubling n quadruples the work, no amount of shape stability will save you.

Measuring in production

Laboratory numbers do not survive contact with real hardware, networks and data. Real-user monitoring (RUM) — PerformanceObserver for Core Web Vitals in browsers, request timings and event-loop lag in servers, exported to your metrics system — shows the distribution users actually experience. Track percentiles per endpoint/route, watch for regressions per deploy, and alert on the tail (p99), which is where timeouts and retries live. This repository's telemetry writes route timings for exactly this reason.

The loop

  1. Set a target (a budget: "p95 under 200 ms", "60 fps while scrolling").
  2. Profile to find the hot spot.
  3. Fix the algorithm first, then the constants.
  4. Benchmark the change against the original, warmed and repeated.
  5. Ship, measure in production, keep or revert.

Optimisations without a measured before/after are guesses, and guesses that complicate code should be reverted.

Common mistakes

  • Timing with Date.now(); single runs; means instead of medians.
  • Benchmarks whose results are unused (eliminated), with literal inputs (folded), or without warm-up (mixed tiers).
  • Micro-optimising a function that takes 0.1% of the profile.
  • Comparing alternatives on different machines/runs without interleaving.
  • Optimising for lab hardware and never measuring in production.
  • Keeping a "faster" version that measurably is not.

Interview angle

  • "How would you find why a Node service is slow?" Profile it (--cpu-prof/--inspect), read the flame chart for self time, check event-loop lag and per-route percentiles, fix the algorithmic hot spot, re-measure.
  • "Why do micro-benchmarks mislead?" JIT warm-up, dead-code elimination, constant folding, GC noise, and measuring effects too small to matter.
  • "Mean or median?" Median plus a high percentile; means hide both the typical case and the tail.
  • "What is a flame chart?" Sampled call stacks over time: width is time spent, depth is call nesting; wide bars are hot spots.
  • "What do you optimise first?" The algorithm (Big-O), then allocation and shape stability in the measured hot spot.

Key takeaways

  • performance.now()/hrtime for durations; Date.now() never.
  • Benchmarks need warm-up, many samples, consumed results, runtime inputs and interleaving; use a harness.
  • Report p50 and p95/p99 with spread; overlapping distributions mean no difference.
  • Profile (--cpu-prof, DevTools flame charts) to find the hot spot; fix complexity before constants.
  • Measure in production per route/percentile; every optimisation has a before and an after or it is a guess.

Common questions

Why do JavaScript micro-benchmarks lie?

Because of JIT warm-up, which mixes interpreted and optimised runs; dead-code elimination, which removes work whose result is unused; constant folding of literal inputs; garbage-collection pauses at unpredictable moments; machine noise; and measuring effects too small to matter next to real I/O.

Should I use performance.now() or Date.now() to measure time?

performance.now(): it is monotonic and sub-millisecond, and it is global in browsers and Node 16 and later. Date.now() is coarse wall-clock time, wrong for durations; for very short spans in Node, process.hrtime.bigint() gives nanoseconds.

How do you read a flame chart?

Time runs along the x-axis and call depth along the y-axis, so a wide bar is a function where much time goes and a tall, narrow stack is deep recursion. Read it top-down for structure, then use the bottom-up table sorted by self time to find the real hot spot.

How do you profile a Node.js application?

Run node --cpu-prof app.js to write a .cpuprofile and open it in Chrome DevTools or speedscope, attach DevTools live with --inspect, or use --prof with --prof-process for a text summary. These sampling profilers run the program at full speed and record the stack about every millisecond.

Why report the median instead of the mean?

A few slow runs caused by garbage collection or page faults drag the mean upwards and hide the typical case, while a few fast runs hide the tail that users notice. Report the median (p50) with a high percentile such as p95 or p99, and the spread.

Mark as readSign in to keep your place.

How to Benchmark and Profile JavaScript and Node.js Code

Benchmarks lie through JIT warm-up, dead-code elimination and GC noise. How to time with performance.now, profile Node and browsers, and read medians and p99.

  • Course: JavaScript study plan
  • Module: Performance and memory
  • Kind: Lesson
  • Reading time: 13 min
  • Runtime: Node 16

How do you benchmark JavaScript code correctly?

Time with performance.now(), never Date.now(), and warm the code up first so the JIT has optimised it before you measure. Feed inputs built at run time, consume every result so the optimiser cannot delete the work, repeat many times, interleave the alternatives, and compare medians and p95 values rather than single runs or means. Libraries such as tinybench and mitata automate this.

Lesson

Almost every performance change made without measuring makes the code worse in some way — less readable, more fragile, or actually slower — because intuition about JIT-compiled code is unreliable and the bottleneck is rarely where it feels. The discipline is simple to state: profile to find the hot spot, benchmark to compare alternatives, measure in production to confirm. Each step has traps: benchmarks that time the wrong thing (JIT warm-up, dead-code elimination, GC pauses), profiles read at the wrong level, and summary numbers (averages) that hide the users who suffer. This lesson covers the tools — performance.now, console.time, --cpu-prof, DevTools, flame charts — and the statistics you need to trust a result: warm-up, repetitions, medians and percentiles, variance, and what "significant" means.

Timing correctly

const t0 = performance.now();            // monotonic, sub-millisecond; global in Node 16+ and browsers
work();
const ms = performance.now() - t0;
process.hrtime.bigint();                 // nanoseconds, for very short spans
console.time("parse"); parse(); console.timeEnd("parse");   // quick and dirty

Date.now() is wall-clock and coarse; never use it for durations. performance.mark/measure label spans that DevTools and PerformanceObserver can display. In browsers, performance.now() is coarsened (to ~100 µs, or 5 µs with cross-origin isolation) for security — fine for anything you can see, useless for nanosecond claims.

Why naive benchmarks lie

const t0 = performance.now();
for (let i = 0; i < 1e6; i++) add(i, 1);       // what did this measure?
  1. JIT warm-up: the first thousands of iterations run interpreted, then get optimised mid-loop — the total mixes tiers. Warm the code before timing, then time a separate run.
  2. Dead-code elimination: add(i, 1)'s result is unused; the optimiser may remove the call entirely and you time an empty loop. Consume results (accumulate and print them).
  3. Constant folding and inlining: a benchmark with literal inputs lets the compiler precompute answers a real workload would not. Feed data from outside the function (an array built at runtime).
  4. GC pauses: an allocation-heavy loop pays for collections at unpredictable moments — a single run's time is noise. Repeat and look at the distribution.
  5. Micro-benchmarks measure micro-effects: a 2× difference on a 5 ns operation is irrelevant next to one database call. Benchmark the operation at the granularity that matters.
  6. Machine noise: turbo boost, other processes, thermal throttling. Run several times, interleave the alternatives, and compare distributions, not single numbers.

Tools that handle this for you: Benchmark.js/tinybench/mitata (warm-up, many samples, statistics), Node's --allow-natives-syntax with %OptimizeFunctionOnNextCall for controlled experiments, and deno bench/bun bench. Write the harness yourself once to see what they do; then use one.

Reading a distribution

Report the median (p50) and a high percentile (p95/p99), not the mean — a few slow runs (GC, page faults) drag the mean up and hide the typical case; a few fast ones hide the tail that users notice. Report the spread (standard deviation, or min/max, or an interquartile range). Two alternatives are distinguishable only when their distributions barely overlap: a 3% difference with 10% run-to-run variance is noise. Discard the first samples (warm-up), use enough repetitions that the median stabilises, and be suspicious of any result you cannot reproduce twice.

Profiling: find the hot spot

Benchmarks compare alternatives you already suspect. A profiler tells you where time actually goes:

  • Node: node --cpu-prof app.js writes a .cpuprofile to open in Chrome DevTools (Performance panel → load) or speedscope; --prof + --prof-process gives a text tick summary; --inspect attaches DevTools live. clinic flame builds flame graphs from a running process.
  • Browser: DevTools → Performance → record → the flame chart (time on the x-axis, call depth on the y-axis: a wide bar is where time goes; a tall narrow stack is recursion) plus the bottom-up table (self time per function). Long tasks over 50 ms are the input-blocking culprits.
  • Sampling (what these do) runs the program at full speed and samples the stack every ~1 ms — cheap and honest; instrumentation (timers around every function) is exact but distorts.

Read a profile top-down for structure and bottom-up for self time. The usual finding: one or two functions account for most of the time, and they are not the ones you guessed. Optimise those; leave the rest alone.

Complexity before constants

Before any micro-work, check the algorithm: a linear scan inside a loop (includes per item — O(n²)), a repeated Object.keys in a hot loop, sorting inside a comparator, re-rendering everything for one change, an N+1 query. Big-O differences swamp every JIT effect: replacing an O(n²) with O(n) on 10 000 items is a 10 000× change; the fastest possible O(n²) is still O(n²). Estimate complexity by measuring operation counts at two or three input sizes — if doubling n quadruples the work, no amount of shape stability will save you.

Measuring in production

Laboratory numbers do not survive contact with real hardware, networks and data. Real-user monitoring (RUM) — PerformanceObserver for Core Web Vitals in browsers, request timings and event-loop lag in servers, exported to your metrics system — shows the distribution users actually experience. Track percentiles per endpoint/route, watch for regressions per deploy, and alert on the tail (p99), which is where timeouts and retries live. This repository's telemetry writes route timings for exactly this reason.

The loop

  1. Set a target (a budget: "p95 under 200 ms", "60 fps while scrolling").
  2. Profile to find the hot spot.
  3. Fix the algorithm first, then the constants.
  4. Benchmark the change against the original, warmed and repeated.
  5. Ship, measure in production, keep or revert.

Optimisations without a measured before/after are guesses, and guesses that complicate code should be reverted.

Common mistakes

  • Timing with Date.now(); single runs; means instead of medians.
  • Benchmarks whose results are unused (eliminated), with literal inputs (folded), or without warm-up (mixed tiers).
  • Micro-optimising a function that takes 0.1% of the profile.
  • Comparing alternatives on different machines/runs without interleaving.
  • Optimising for lab hardware and never measuring in production.
  • Keeping a "faster" version that measurably is not.

Interview angle

  • "How would you find why a Node service is slow?" Profile it (--cpu-prof/--inspect), read the flame chart for self time, check event-loop lag and per-route percentiles, fix the algorithmic hot spot, re-measure.
  • "Why do micro-benchmarks mislead?" JIT warm-up, dead-code elimination, constant folding, GC noise, and measuring effects too small to matter.
  • "Mean or median?" Median plus a high percentile; means hide both the typical case and the tail.
  • "What is a flame chart?" Sampled call stacks over time: width is time spent, depth is call nesting; wide bars are hot spots.
  • "What do you optimise first?" The algorithm (Big-O), then allocation and shape stability in the measured hot spot.

Key takeaways

  • performance.now()/hrtime for durations; Date.now() never.
  • Benchmarks need warm-up, many samples, consumed results, runtime inputs and interleaving; use a harness.
  • Report p50 and p95/p99 with spread; overlapping distributions mean no difference.
  • Profile (--cpu-prof, DevTools flame charts) to find the hot spot; fix complexity before constants.
  • Measure in production per route/percentile; every optimisation has a before and an after or it is a guess.

Common questions

Why do JavaScript micro-benchmarks lie?

Because of JIT warm-up, which mixes interpreted and optimised runs; dead-code elimination, which removes work whose result is unused; constant folding of literal inputs; garbage-collection pauses at unpredictable moments; machine noise; and measuring effects too small to matter next to real I/O.

Should I use performance.now() or Date.now() to measure time?

performance.now(): it is monotonic and sub-millisecond, and it is global in browsers and Node 16 and later. Date.now() is coarse wall-clock time, wrong for durations; for very short spans in Node, process.hrtime.bigint() gives nanoseconds.

How do you read a flame chart?

Time runs along the x-axis and call depth along the y-axis, so a wide bar is a function where much time goes and a tall, narrow stack is deep recursion. Read it top-down for structure, then use the bottom-up table sorted by self time to find the real hot spot.

How do you profile a Node.js application?

Run node --cpu-prof app.js to write a .cpuprofile and open it in Chrome DevTools or speedscope, attach DevTools live with --inspect, or use --prof with --prof-process for a text summary. These sampling profilers run the program at full speed and record the stack about every millisecond.

Why report the median instead of the mean?

A few slow runs caused by garbage collection or page faults drag the mean upwards and hide the typical case, while a few fast runs hide the tail that users notice. Report the median (p50) with a high percentile such as p95 or p99, and the spread.

Exercises

Benchmark statistics

Line 1 is warmup=<k>; each following line is <name>: <sample> <sample> … (milliseconds). Drop the first k samples of each series (the JIT warm-up), then print <name>: n=<> min=<> median=<> mean=<> p95=<> stddev=<> outliers=<> with two decimals: p95 by nearest rank, the sample standard deviation, and outliers counted as samples more than 3 × 1.4826 × MAD from the median. If there are two series, compare the second to the first: <b> vs <a>: median <±x.xx>% -> <verdict> where the verdict is not distinguishable (IQRs overlap) when the interquartile ranges (p25–p75) overlap, else faster or slower.

Example: warmup=2, baseline: 40 25 12.1 12.4 12.0 12.2 12.9 12.3 12.1 30.0 12.2, candidate: 38 24 11.0 11.2 10.9 11.1 11.3 11.0 11.2 11.1 11.0 →

baseline: n=9 min=12.00 median=12.20 mean=14.24 p95=30.00 stddev=5.91 outliers=2
candidate: n=9 min=10.90 median=11.10 mean=11.09 p95=11.30 stddev=0.13 outliers=0
candidate vs baseline: median -9.02% -> faster

The baseline's mean is dragged to 14.24 by one GC pause; its median tells the truth.

Fit the complexity

Each line is <n> <operations> — an operation count measured at input size n. For each model in the starter's table (O(1), O(log n), O(n), O(n log n), O(n^2)) compute operations / f(n) per point and the coefficient of variation of those ratios (population standard deviation ÷ mean): a perfect fit gives a constant ratio, so the smallest CV wins. Print every model as <name padded to 10> cv=<x.xxx> sorted ascending, then best fit: <name>, then n x<ratio> -> ops x<ratio> from the first to the last point (one decimal).

Example: 10 100, 20 400, 40 1600, 80 6400 →

O(n^2)     cv=0.000
O(n log n) cv=0.527
O(n)       cv=0.715
O(log n)   cv=1.088
O(1)       cv=1.191
best fit: O(n^2)
n x8.0 -> ops x64.0

In this module: Performance and memory

← How V8 runs your code — parsing, tiers, hidden classes and inline caches · Memory and garbage collection — generations, reachability, and the leaks that survive it →