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
| Metric | Measures | Moved by | NOT moved by |
|---|---|---|---|
| TBT (Total Blocking Time) | main-thread blocked >50ms during load | JS parse/exec, hydration, boot graph size | CSS delivery method, image weight |
| LCP (Largest Contentful Paint) | when the biggest element paints styled | render-blocking CSS, the largest element's styles, JS that gates it | TBT directly |
| FCP (First Contentful Paint) | first pixel of content | critical CSS / render-blocking head, server response | which CSS-delivery method you pick |
| Perf score | Lighthouse weighted blend | all 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:
- 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.
- 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.
- 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.
- Compare by FLOOR (min of ≥6–8 runs), not median. The floor strips transient contention; the median rides it.
- Measure A and B back-to-back, same machine, same sitting. Never compare a fresh number to a historical one from a different load.
- 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. - 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.
- 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
- Markers must be SDK-internal symbols, never package-name substrings. Grepping a chunk for
@sentry/orfirebasematches 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. - 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.
- 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.
- 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. onLoadon a<link>is stripped by React inside_document(Next.js pages router). A preload-then-swap non-blocking-CSS pattern that relies ononLoadsilently never fires — the async stylesheet never applies, and styles go missing on client-side navigation. Prefer themedia="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).- 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.
- 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?