Measuring performance without fooling yourself

Applies to perf.lcp, perf.score-floor, perf.caching-and-compression.

Covers perf.lcp, perf.score-floor, perf.caching-and-compression.

Every rule in this section exists because a wrong conclusion was once drawn from a real measurement. None of them are theoretical.

Six rules

1. Measure the live URL with the PageSpeed Insights API, not a local Lighthouse run. A local run measures the reviewer's laptop, their network, their extensions and their CPU throttling. Two people get two different answers for the same site and argue about the site.

2. Take the median of at least three runs, and quote the spread. Identical bytes have scored 59 and 95 within minutes of each other. A single run is an anecdote. "91, median of 5, range 87 to 96" is a measurement. Anything reported as one number should be assumed to be one run and re-measured.

3. Bust the report cache with ?cb=N, never ?p=N. PSI caches its own reports, so consecutive runs can return the same stored result. p is a reserved query variable in WordPress and redirects, so ?p=2 measures the redirect rather than the page.

4. Warm the cache with curl --compressed, not plain curl. A cache keyed on content encoding will otherwise warm an entry the test never requests, and every "warm" figure is actually a cold one.

5. Check Content-Encoding before reasoning about bytes. Some hosts compress nothing. If the host is sending the raw file, every estimate made from a gzipped size is wrong - a 71 KB stylesheet was read as "11 KB gzipped" for weeks on one host that sent no compression at all.

6. A ?cb= buster bypasses page caches too. So a page cache cannot show up in numbers measured this way. Do not install one expecting it to.

Know the weights before choosing a target

Metric Weight
First Contentful Paint 10%
Speed Index 10%
Largest Contentful Paint 25%
Total Blocking Time 30%
Cumulative Layout Shift 25%

TBT and CLS together are 55% of the score. If both are already zero, more than half the score is banked and only LCP is worth any work. Optimising the wrong metric is the most common way to spend a day and move nothing.

When LCP is the only red metric, find the element first

This is the one that costs the most time when skipped. The LCP element is usually being discarded, not loading slowly.

Chrome permanently drops an element from LCP consideration if it finishes loading while it is not visible. So an entrance animation on the hero does not delay LCP, it disqualifies the hero and promotes something else - usually something below it that loads much later.

opacity: 0 does this. So do clip-path, overflow: hidden and visibility: hidden. They are not loopholes; they are all treated as "not visible".

You cannot fade in your largest above-the-fold element and have a fast LCP. Either the element is visible from the first frame, or it is not the LCP element, and something worse is.

Find it before touching payload:

new PerformanceObserver((list) => {
  const entry = list.getEntries().at(-1);
  console.log(entry.element, entry.startTime, entry.url);
}).observe({ type: "largest-contentful-paint", buffered: true });

Or read it from the LCP audit in the report. Then optimise that element. Halving the size of an image that is not the LCP element changes nothing.

Reporting

A performance claim in a handover names three things: the metric, the median with its spread, and the single change it came from. Two changes in one deploy means neither result is attributable, which is why practices.one-change-per-deploy exists.