InterviewsVector
intermediateCommon6 min read · Updated Aug 23, 2026

How to Profile and Measure JavaScript Performance

Measure JavaScript performance before you optimize: performance.now vs Date.now, the User Timing API, PerformanceObserver, DevTools flame charts, and the micro-benchmark traps that make numbers lie.


The problem

You claim your change made the page faster. Prove it. How do you measure, and how do you know the number you got is real?

This is the question underneath every performance interview, and it's the one candidates skip. The senior instinct isn't "I know a faster way" — it's "measure first, optimize the hot path, measure again." Optimizing without a baseline is how people spend a day rewriting code that was never the bottleneck.

The one-liner: you can't optimize what you haven't measured, and you can't trust a measurement you took once.

Where you see it in production

  • Regression gates — CI fails the build if a benchmark or a Core Web Vital (LCP, INP) crosses a threshold.
  • RUM (Real User Monitoring)PerformanceObserver reports field metrics from real devices, not your fast laptop.
  • Flame-chart hunts — the DevTools Performance panel shows where the main thread went, so you fix the 200 ms function instead of the 2 ms one.
  • A/B rollouts — you ship the "faster" version to 5% and compare percentiles before rolling out.

The wall clock: performance.now(), not Date.now()

const start = performance.now();
doWork();
const ms = performance.now() - start;
console.log(`doWork took ${ms.toFixed(2)}ms`);

Two reasons performance.now() beats Date.now():

  • Monotonic — it never jumps backward. Date.now() follows the system clock, which NTP sync or the user can move mid-measurement, producing negative or wildly wrong durations.
  • Sub-millisecond — it returns a high-resolution float (though browsers clamp precision to mitigate timing attacks). Date.now() is integer milliseconds, useless for anything that runs in under ~1 ms.

The User Timing API: mark and measure

Ad-hoc performance.now() diffs don't show up anywhere. The User Timing API records named marks and measures onto the same timeline DevTools draws, so your spans appear right next to browser events.

performance.mark("parse-start");
const data = JSON.parse(hugePayload);
performance.mark("parse-end");
 
performance.measure("parse", "parse-start", "parse-end");
 
const [entry] = performance.getEntriesByName("parse");
console.log(entry.duration); // ms between the two marks

Those "parse" measures render as labeled bars in the Performance panel's Timings track — the cheapest way to answer "which phase is slow?" without a profiler.

Watching the field: PerformanceObserver

Polling performance.getEntries() misses events and forces you to guess when to look. PerformanceObserver pushes entries to you as they happen — this is how RUM libraries capture long tasks and paint metrics on real users' devices:

// Anything that blocks the main thread for >50ms is a "long task"
const obs = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.warn(`Long task: ${entry.duration.toFixed(0)}ms`, entry);
  }
});
obs.observe({ type: "longtask", buffered: true });

Swap the type for "largest-contentful-paint", "layout-shift", "event" (INP), or "paint" to capture the metrics Google actually ranks on.

The DevTools Performance panel

performance.now() tells you how long; the flame chart tells you where. Record, then read it top-down:

  • Wide bars = expensive. Width is time. Ignore the tall-but-thin frames.
  • Self time vs total time. A function with high total but low self time is slow because of what it calls — chase the callee. High self time means the function itself is the cost.
  • Bottom-Up tab aggregates self time across every call, so a function called 10,000 times in tiny slices rises to the top even though no single frame looks wide.
  • Long, unbroken yellow (scripting) blocks are what freeze the UI — the thing your longtask observer is catching.

console.time("label") / console.timeEnd("label") is the two-line version for quick console work; console.profile() / console.profileEnd() starts and stops a CPU profile programmatically.

Micro-benchmark traps: why your number lies

Most "X is faster than Y" benchmarks are wrong because of how a JIT-compiled, garbage-collected runtime behaves. Guard against all of these:

  • JIT warmup. V8 runs code in an interpreter first, then optimizes hot functions. The first iterations run interpreted and are far slower. Discard warmup runs.
  • Dead-code elimination. If you never use the result, the optimizer may delete the work entirely and you'll "benchmark" an empty loop. Always consume the output (accumulate it, log it).
  • GC pauses. A collection during timing adds a spike unrelated to your code. Run many iterations and report the median or a low percentile, not the mean (which one GC pause skews).
  • One sample is noise. Run the loop thousands of times, repeat the whole thing several times, and compare distributions.
function bench(fn, iterations = 100_000) {
  for (let i = 0; i < 1000; i++) fn(); // warm up the JIT, timing nothing
 
  const runs = [];
  for (let r = 0; r < 7; r++) {
    const start = performance.now();
    let sink; // consume the result so it can't be optimized away
    for (let i = 0; i < iterations; i++) sink = fn();
    runs.push(performance.now() - start);
    if (sink === Symbol()) console.log(sink); // unreachable; keeps `sink` live
  }
  runs.sort((a, b) => a - b);
  return runs[Math.floor(runs.length / 2)]; // median run
}
Predict the outputA dev benchmarks two array methods, each in its own tight loop, and reports the mean of a single run. Their result flips when they swap which one runs first. Most likely cause?
// pseudo
const t1 = time(() => methodA());
const t2 = time(() => methodB()); // runs second

The workflow interviewers want to hear

  1. Reproduce the slowness with a realistic input, on throttled CPU (DevTools 4×–6× slowdown) — your machine is not the user's phone.
  2. Profile to find the hot path. Don't guess.
  3. Fix the biggest bar first — usually an algorithm (complexity trade-offs), sometimes a layout thrash or a memory leak.
  4. Re-measure to confirm the win is real and didn't move the cost elsewhere.
  5. Guard it with a metric so it can't regress silently.

See this loop applied end to end in Optimize a Slow Function.

Interview follow-ups to be ready for

  • "Mean or median?" Median (or p75/p95). Means are dragged around by GC-pause outliers; user experience lives in the tail, so percentiles are what you report.
  • "Why throttle the CPU?" Your dev machine hides jank that a mid-range phone shows. Field data (RUM) beats lab data for the same reason.
  • "performance.now() isn't sub-microsecond precise anymore — why?" Browsers coarsen timer resolution to defend against Spectre-style timing attacks. Run more iterations to recover signal.
  • "What's INP / LCP and how do you measure them?" PerformanceObserver with event (INP, interaction latency) and largest-contentful-paint (LCP) types — the Core Web Vitals that gate search ranking and are best captured in the field.

  • Big-O reasoning the way interviewers want it: the Map/Set/object/array cheat sheet, hidden O(n) costs like spread and includes, and trading memory for speed.

  • How GC reachability actually works, the four classic leaks, WeakMap/WeakRef, and finding retained memory with heap snapshots.

  • Why interleaving DOM reads and writes forces synchronous layout, the read/write batching fix, requestAnimationFrame, and compositor-only properties.

  • A hands-on exercise: profile first, fix the algorithm, hoist invariant work, cut allocations, memoize, then chunk off the main thread — in that order.