A speed test that produces a number and no decision is a waste of an afternoon. This checklist is ordered the way the work should actually happen: fix the thresholds first, make the lab match a real phone second, and only then start changing code. Every step has a pass or fail condition so you can stop arguing about whether the site feels fast.
Core Web Vitals are evaluated at the 75th percentile of real users, over a rolling 28-day window, split by mobile and desktop. The "good" boundaries are LCP at or under 2.5 seconds, INP at or under 200 milliseconds, and CLS at or under 0.1. Add a TTFB budget of roughly 800 milliseconds, which is not a ranking metric but is the ceiling on everything downstream.
The p75 detail matters more than people expect. A median of 1.8 seconds tells you nothing if the tail is at 6. Write the target down as "mobile p75 LCP under 2.5s" and the rest of the checklist has a purpose.
Lighthouse, WebPageTest and your local dev tools produce lab data: one synthetic run, on one connection, with no real users. CrUX and your own RUM produce field data: what actually happened. Lab data is for diagnosing, field data is for deciding. Chasing a Lighthouse score of 100 while field LCP sits at 4 seconds is a common and expensive mistake, usually caused by testing on a desktop with a warm cache and a fast connection.
Run lab tests with mobile emulation, Slow 4G throttling and a 4x CPU slowdown, and take the median of five runs rather than the best one. Variance between single runs of 15 to 20 percent is normal.
Measure it without any browser noise:
curl -o /dev/null -s -w 'dns:%{time_namelookup} tls:%{time_appconnect} ttfb:%{time_starttransfer} total:%{time_total}\n' \
https://example.com/
If time_starttransfer minus time_appconnect is large, the problem is your server or database, not the network. If time_appconnect is large, you are paying for a distant origin and want a CDN with the HTML cached, not just the assets. Check that HTML responses carry a sensible Cache-Control with s-maxage and stale-while-revalidate, and that hashed assets get max-age=31536000, immutable. Confirm Brotli is on for text responses; a missing content-encoding: br on your JS bundle is a free 15 to 20 percent.
Identify it first, in DevTools' Performance panel or via the LCP entry in RUM. It is almost always a hero image or a heading. Then, in order:
loading="lazy" from it. Lazy-loading the LCP image is the single most common self-inflicted regression.fetchpriority="high" so it jumps the queue ahead of scripts and other images.<picture>, and give it srcset plus an accurate sizes. A wrong sizes value silently downloads the 2000px variant on a phone.preconnect to that origin. Use preload only when the URL is known in the HTML and the discovery is late.Set font-display: swap so text paints immediately, subset the font to the characters you use (a Latin subset is typically a fraction of the full file), preload only the one woff2 that renders above the fold, and self-host rather than adding a third-party connection to the critical path. To stop the swap from causing a shift, match the fallback metrics with size-adjust and ascent-override on a local fallback face. For CLS generally: width and height or aspect-ratio on every image and iframe, reserved space for ads and embeds, and never inject a banner above existing content after load.
INP measures the worst interaction latency a real user experienced, so it does not appear in a Lighthouse run that never clicks anything. Test it by hand on a mid-range Android, or with a 4x CPU throttle and actual interaction. The usual culprits are long tasks: a single 300 millisecond handler blows the budget on its own. Break work up with scheduler.yield() where supported, move parsing off the main thread, and stop rendering 500 rows when 50 are visible. For tag managers, chat widgets and A/B tools, load on interaction or on idle rather than in the head, and audit them quarterly, because that list only ever grows.
A checklist you run once decays within a quarter. Put a bundle budget in CI with size-limit or a bundler's own budget setting, fail the build when a route's JavaScript grows past its ceiling, and add Lighthouse CI assertions for LCP and CLS on two or three key templates. Keep a WebPageTest filmstrip of the homepage from before and after each significant change, because the filmstrip is the artefact that ends debates with stakeholders far faster than a score. If you want the wider context on what shifted in the metrics themselves, the what changed in speed testing piece covers it.