Reading a profile

Find the real bottleneck instead of the one you assumed: read a flame chart, separate self time from total time, and know what a speed-up is worth before you do it.

15of 16
≈ 13Min read
5Parts

Measure first, always

Every engineer has spent a day optimising something that turned out to be 2 % of the runtime. The instinct about what is slow is wrong often enough that acting on it without a profile is gambling.

And the arithmetic is unforgiving: if a function is 20 % of the total, making it infinitely fast gains you 20 %. That is Amdahl’s law, and it is the first thing to check before starting — what is the best case, and is it worth the day?

Self time and total time

A profiler gives two numbers per function. Total (or "inclusive") is the time spent in it and everything it called. Self (or "exclusive") is the time spent in its own body.

A function at the top of the total list is often just a caller — main is always 100 %. The function to optimise is the one high on the self list; that is where the CPU actually was.

the same profile, two ways to read it
total   self    function
1000    100     root
 400     60       parse
 250    250         tokenize     ← the actual work
  90     90         build
 500     80       render
 300     20         layout
 280    280           measure    ← and here
 120    120         paint

The panel, briefly

  • Record only the interaction you care about. A 60-second trace is unreadable; three seconds around one click is not.
  • Throttle the CPU to 4× or 6× and the network to Slow 4G. Otherwise you are profiling your own laptop, which nobody uses.
  • Flame chart = the call stack over time; width is duration, depth is nesting. Bottom-up = the self-time list. Start with bottom-up, then find it in the flame chart to see who calls it.
  • Purple is layout and paint, yellow is scripting. A wall of purple after your JavaScript means layout thrashing, not slow code.
  • Look for repetition. The same stack fifty times is usually one loop doing something per item that could be done once.

Marking your own code

performance.now() is a monotonic clock in fractional milliseconds — it never jumps backwards the way Date.now() can. In cross-origin-isolated pages the resolution is 5 µs; otherwise it is deliberately coarsened to about 100 µs to make timing attacks harder, which is another reason a single measurement of something fast is meaningless.

For production, the Long Animation Frames API (observe({ type: "long-animation-frame" })) is the one to know: it reports slow frames with the script that caused them, which the old Long Tasks API never did.

User Timing: your names, in the profiler and in RUM
performance.mark("render:start");
renderTable(rows);
performance.mark("render:end");

const { duration } = performance.measure("render", "render:start", "render:end");

/* the same names appear in the Performance panel's timings track,
   and you can ship them: */
new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) report(entry.name, entry.duration);
}).observe({ type: "measure", buffered: true });

Micro-benchmarks lie

  • Warm up. The first runs are interpreted; the JIT optimises later ones. Measuring the first is measuring the wrong thing.
  • Use the result. An engine will delete a computation whose result nothing reads.
  • Take the median of several runs, never one — and compare medians, never best cases.
  • Beware the toy input. An algorithm that wins on ten items can lose on ten thousand, and vice versa.
  • Then measure the real page anyway. A function that is twice as fast and 0.3 % of the load is a rounding error.

Practice