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.
The one escape hatch: LCP does no occlusion testing
Visible means visible to the algorithm, not to the person. An element painted at full opacity with something opaque on top of it is still a valid LCP candidate, because nothing checks what is in front of it. That is the only way to have a preloader and a fast LCP at the same time.
So the hero image loads eagerly at fetchpriority="high" and paints visible, an opaque overlay
covers it while the preloader runs, and the entrance animation stamps its start state before the
overlay lifts. The image was never hidden, so it was never discarded. Doing it the obvious way
round, hiding the image and revealing it after the preloader, disqualifies it: a later reveal is
not a new paint and emits no entry at all.
The ordering is the whole trick and it is easy to break by accident. Anything that sets the start state of the animation before the paint, rather than after, puts the element back into the hidden case.
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.
Write those three into a file in the repository, one row per run, and commit it. A number in a message thread is a number nobody can re-check; a number in the repository beside the commit that produced it can be argued with a year later. One row carries the date, a label naming the change, the URL, the strategy, the run count, the median of every metric and the spread:
date,label,url,strategy,runs,score_med,FCP_med,LCP_med,SI_med,TBT_med,CLS_med,spread
2026-08-06T21:13:56Z,baseline-gsap,https://example.com/,mobile,5,84,2401,3001,6557,121,0,32
2026-08-06T21:52:58Z,analytics-deferred,https://example.com/,mobile,5,88,2251,3001,6079,95,0,12
The label is what makes it worth keeping. Two adjacent rows with the same URL and strategy and different labels are a before and an after, and the spread column is what stops a 4-point move being read as a win when the runs varied by 30.