Skip to main content

Measuring Correctly

How to measure TBT/LCP/FCP correctly with @codeleap/perf and avoid drawing wrong conclusions from the numbers — most importantly, how not to lose the measurement context, the single most common way people misread a result.

The metrics — what each one actually measures

MetricMeasuresMoved byNOT moved by
TBT (Total Blocking Time)main-thread blocked >50ms during loadJS parse/exec, hydration, boot graph sizeCSS delivery method, image weight
LCP (Largest Contentful Paint)when the biggest element paints styledrender-blocking CSS, the largest element's styles, JS that gates itTBT directly
FCP (First Contentful Paint)first pixel of contentcritical CSS / render-blocking head, server responsewhich CSS-delivery method you pick
Perf scoreLighthouse weighted blendall of the above

The single most important distinction:

Render-block (FCP/LCP) ≠ main-thread JS (TBT). How you ship CSS affects FCP/LCP. How much JS is on boot affects TBT. They are different levers, and a change can move one and not the other. Always say which metric a change targets.

Avoiding context loss — the discipline

"Context loss" here means losing the reference frame of a number, so a comparison ends up being apples to oranges. Rules:

  1. A bare number is meaningless. Always record its conditions: machine load, build state (what changed vs. the baseline), and the number of runs. "TBT 199" tells you nothing; "TBT floor 133 at load 4.22" is comparable.
  2. Machine load is the #1 trap. The exact same build can measure TBT 148 at load 1.9 and TBT 275 at load 5–7; LCP can swing by hundreds of milliseconds purely from load. If you don't log the load, a contention spike reads as a regression.
  3. Load spikes right after a reboot (indexing, sync). Load can spike dramatically in the first few minutes after boot — don't measure then; let the machine idle first.
  4. Compare by FLOOR (min of ≥6–8 runs), not median. The floor strips transient contention; the median rides it.
  5. Measure A and B back-to-back, same machine, same sitting. Never compare a fresh number to a historical one from a different load.
  6. Anchor baselines to disk, not memory. codeleap-perf analyze --json <file> writes the full report — metrics, load average, and timestamp — to disk. Absolute numbers drift across machines and over time; the baseline-plus-conditions pair is the actual anchor, not a number you remember.
  7. Find a stable cross-load signal. When load is noisy, look for the metric that doesn't move with load to read the real effect. FCP is usually far more load-stable than LCP — if LCP bounces around while FCP holds steady, that swing is load, not the change under test.
  8. Keep a running log of what you tried and what you found — not just the numbers. A JSON baseline (point 6) tells you what the floor was; it doesn't tell you why you were measuring, what you changed, or what you concluded. Keep a plain markdown log, one entry per investigation: what was tested, the before/after floor, and the conclusion — including a negative one ("doubled X, floor didn't move — not a lever, don't re-investigate"). Without this, the same "is this actually a factor?" question gets re-investigated from scratch every few months by whoever touches that code next, because nothing on disk says it was already checked.

Attribution gotchas

  1. Markers must be SDK-internal symbols, never package-name substrings. Grepping a chunk for @sentry/ or firebase matches the lazy-import('...') call-site strings and false-positives — use internals instead (Sentry: BrowserClient|makeFetchTransport; firebase: @firebase/auth|EmailAuthProvider|FirebaseAppImpl; Zod: safeParse|ZodError). Cross-check a hit against total boot-JS size — a real leak moves it, a false positive doesn't.
  2. CPU self-time ≠ TBT. A function can have high self-time and add ~0 TBT if it runs outside the >50ms blocking window. Only the floor delta from the subtract method (below) proves a real TBT cost.
  3. The subtract method is the only reliable attribution. Rebuild with exactly one thing removed or changed, re-measure the floor. The delta is the real contribution — a CPU profile only suggests where to look.
  4. A dynamic import() is a code-split boundary, not just lazy-loading. Removing one can fold a whole graph — and any cross-chunk duplication of it — into the initial chunks, even though nothing about what runs changed, only when it's parsed.
  5. onLoad on a <link> is stripped by React inside _document (Next.js pages router). A preload-then-swap non-blocking-CSS pattern that relies on onLoad silently never fires — the async stylesheet never applies, and styles go missing on client-side navigation. Prefer the media="print"media="all" inline-script swap if you need this pattern at all (see Improving Boot Performance for when it's not safe to use it).
  6. Measure HTML/transfer separately from render. A transfer-size win — e.g. moving inline data to a cached file — can show zero FCP/LCP change; cutting HTML size is invisible to render metrics unless it was gating render.
  7. Headless Chrome can under-report LCP. Run the same build headed (a real, visible Chrome window) and headless back to back and the LCP floor can differ substantially — TBT and FCP read about the same in both modes, but LCP does not. Default to a headed run for any LCP number you intend to trust, and never mix the two modes within one comparison.

Quick checklist before trusting a perf conclusion

  • Logged machine load with the number?
  • Floor of ≥6–8 runs, not median?
  • A and B measured back-to-back, same machine, same sitting?
  • Used SDK-internal markers, not package-name substrings?
  • Cross-checked a marker hit against total boot-JS size?
  • Confirmed attribution with the subtract method, not just CPU self-time?
  • Said which metric — TBT vs. FCP/LCP — the change targets?
  • Logged what was tested and found to a running memory log, not just left in a JSON baseline?