Three numbers from Google's Core Web Vitals: 2.5 seconds, 0.1 units, 200 milliseconds. If your page hits those three thresholds at the 75th percentile of real-user traffic, you pass. If it does not, your search ranking takes a small hit, your conversion rate takes a bigger hit, and your users feel it whether or not they can articulate why. The numbers are not arbitrary. Each one corresponds to a specific user-felt experience, and once I started thinking of them that way instead of as Lighthouse score-gaming targets, I stopped writing the kinds of "optimizations" that improve the lab number while making the field experience worse.
The stance, said up front: Web Vitals matter, but they matter as proxies for real user experience, not as numbers to optimize for their own sake. A page can have a 95 Lighthouse score and feel terrible to actual users, and a page can have a 60 Lighthouse score and feel great. The gap between lab and field is where most performance work goes wrong, and the way out of the gap is to understand what each metric actually measures and to instrument the field, not the lab.
What each number actually measures
Three metrics, each a proxy for one user complaint:
The thresholds are 75th-percentile targets across mobile and desktop traffic for the page. "Good" means three out of four real users had an experience under the threshold. The thresholds are not the median, which is what teams sometimes report, and they are not the worst case, which is what some monitoring tools chase. Seventy-fifth percentile is the deliberate middle ground: most users in the green, accepting that a small fraction will always have slow connections or old devices.
LCP: when the page looks loaded
Largest Contentful Paint measures when the largest visible image, video, or text block on the page finishes rendering, relative to navigation start. The user's mental model of "the page is loaded" maps roughly to that moment. The hero image, the headline, the main chart, whichever is largest in the viewport.
The failure modes:
- The hero image is downloaded last. The page loads CSS, JS, fonts, analytics, three tracking pixels, and only then starts fetching the 200 KB hero. LCP fires at 4 seconds. The fix is
<link rel="preload" as="image" href="..." fetchpriority="high">in the head, or thefetchpriority="high"attribute on the<img>itself. The browser starts the image fetch in parallel with everything else. - The hero is render-blocked by JavaScript. A Server-Side-Rendered hero would paint at 800 ms; a Client-Side-Rendered hero waits for the bundle to download and execute and only then renders, pushing LCP past 3 seconds on a phone. The fix is rendering the hero on the server, or at least shipping the hero markup in the HTML and hydrating later.
- Fonts blocking text rendering. With
font-display: block(the historical default for@font-face), text waits for the web font to download before painting at all. If your LCP candidate is a text headline, you just doomed it. Usefont-display: swaporfont-display: optional, accept the brief flash of fallback font, and let the headline paint immediately. - The LCP element is below the fold. This sometimes catches teams who have a small headline above the fold and a large image below it; the image is the LCP candidate even though the user does not see it for a while. The fix is structural: make sure the largest element in the initial viewport is the one you care about loading fast.
The debugging move: in Chrome DevTools, open the Performance Insights panel, run a trace, and it will tell you which element was the LCP candidate and which sub-event (resource fetch, parse, render) was on the critical path. That information turns LCP from a number to a specific code change.
CLS: when the layout stops jumping
Cumulative Layout Shift measures unexpected movement of visible content. Every time something shifts on the page after it has appeared, the shift contributes to a score (roughly: how much of the viewport moved, multiplied by how far). The score accumulates across the whole page lifetime. The 0.1 threshold catches almost all the painful jumps.
The canonical CLS bug: an image without dimensions. Browser starts laying out the page, image element is empty, image takes zero height, text below sits at the top of where the image will be. Image loads, browser knows the dimensions, image takes 400 pixels, text below shifts down 400 pixels. The user was reading the text, the text moved, they lost their place. CLS = 0.5 (terrible).
The fix is reserving the space:
Other CLS sources I keep finding:
- Ads inserted asynchronously. Ad slots load late and push content down. Reserve space for ad slots at known dimensions, even if the ad is not loaded yet. If the ad does not fill, leave the space blank.
- Web fonts swapping in. A font swap that changes letter widths shifts everything by a few pixels. Use
size-adjustandascent-overrideto match the fallback font's metrics to the web font's, so the swap is invisible. Modern font stacks (Inter,system-ui) often need no overrides because their metrics are already close to the fallback. - Banners injected after page load. A cookie banner or notification injected at the top of the page pushes everything down. Position it as a fixed overlay or reserve space for it before paint.
- Dynamic content insertion in response to user action. This one does not count against CLS if it happens within 500 ms of user input (the metric explicitly excludes user-initiated shifts). But shifts from auto-loading recommendations or autoplaying carousels do count and are common offenders.
The debugging move: in Chrome DevTools, the Performance panel has "Layout Shifts" entries that highlight which element moved and by how much. Each one is a fixable bug; the number is the count of those bugs weighted by impact.
INP: when the page responds to a tap
Interaction to Next Paint replaced First Input Delay (FID) as a Core Web Vital in March 2024. INP measures the latency from a user interaction (click, tap, key press) to the next visual update of the page, across the entire page session, taking roughly the worst (highest) interaction at the page's 75th percentile.
INP is harder to fix than LCP or CLS because it is not a one-shot measurement. Every interaction during the session contributes a candidate value, and the worst one (or close to it) is what gets reported. A page can pass LCP and CLS easily and still fail INP if any single interaction during the user's visit was slow.
The causes:
- Long event handlers blocking the main thread. A click handler that does 500 ms of synchronous work freezes the page for 500 ms. The fix is to do the urgent work synchronously (update the optimistic UI) and defer the expensive work (
setTimeout,requestIdleCallback, off the main thread via Web Workers). - Long renders triggered by state changes. A click sets state, the state change triggers a render of a 5,000-element list, the render takes 800 ms. The fix is
useDeferredValueoruseTransitionin React, or virtualization (react-window, TanStack Virtual) so only visible rows render. - Long tasks from third-party scripts. Analytics, ad networks, and chat widgets are common culprits. Their scripts run on the main thread and can block input handling for hundreds of milliseconds. The fix is moving them to be lazy-loaded after the user is settled, or moving them to a Web Worker if the script supports it (Partytown is the common option here).
- Hydration of large client trees. A Next.js page that renders fast on the server and then takes 1 second to hydrate on the client has a window during hydration where input is unresponsive. The fix is the React Server Components / islands pattern: only the genuinely interactive parts hydrate; the rest is static HTML.
The debugging move: in Chrome DevTools, the Performance Insights panel surfaces "Long tasks" and tells you which interaction was the worst. The fix is almost always one of: less synchronous JavaScript, smaller render trees, third-party offloading.
The fourth one nobody measures: TTFB
Time To First Byte is technically not a Core Web Vital but it is the foundation everything else stands on. If TTFB is 1.5 seconds, LCP cannot be under 2.5 seconds. The browser is waiting on the server before any rendering can start.
My threshold for TTFB: under 600 ms at the 75th percentile. Above that, your origin is too slow or too far from your users, and no amount of front-end optimization will make LCP good. Common causes:
- Database queries on the critical path that are not cached.
- Origin in one geographic region serving users globally.
- Cold starts on serverless functions.
- Render-blocking work in the server-side render path.
A CDN with edge caching for ISR, SSG, or even SSR with a short cache window typically halves TTFB for non-personalized pages. For personalized pages, the answer is usually pushing more work to a CDN edge function that can hit a regional database or cache, instead of round-tripping to the origin.
Lab vs field: the gap that fools teams
The single biggest mistake teams make with Web Vitals: optimizing the Lighthouse score (lab) and assuming the field score will follow. Lab and field measurement differ on three dimensions:
- Network conditions. Lighthouse uses simulated throttling (Slow 4G, fast 4G, etc.); real users have a long tail of actual conditions, including the user with bad WiFi in a basement coffee shop.
- Device. Lighthouse runs on a beefy CI server simulating a mid-tier mobile CPU. Real users are on a five-year-old Android phone, an iPhone 8, a Chromebook in a school district. CPU performance varies by 10x or more across the actual user population.
- User behavior. Lighthouse loads the page once and measures. Real users navigate, interact, scroll, click. INP especially is captured across the whole session, which the lab cannot simulate.
The consequence: a page can score 95 in Lighthouse and have terrible field metrics because the page's heavy interactivity was not exercised by the synthetic test. Or a page scores 70 in Lighthouse because of synthetic-test artifacts but performs great in the field because real users have good connections.
The right approach is field measurement: real-user monitoring (RUM) that captures Web Vitals from actual sessions and sends them back to your analytics. Google Search Console exposes the field data Google itself uses for ranking; it is the source of truth for whether your page "passes". Tools like Sentry, DataDog RUM, Speedcurve, and Vercel Speed Insights all collect field vitals. Pick one, instrument it, and look at the field data, not the lab.
Lighthouse and other lab tools are useful for one specific job: regression detection in CI. "Did this PR make the page slower than the previous baseline?" Lab measurement is consistent enough to answer that. "Is the page fast enough for users?" requires field data.
Run this on your hottest page tomorrow
The practical first step for any team that has not done this: open Google Search Console for your site, go to the Core Web Vitals report, and look at the URLs that fail at p75. That list is your prioritized backlog. For each URL, run a DevTools Performance trace on a real device or with throttling, identify the LCP element, look for layout shifts, watch for long tasks during interaction. Each of those gives you a specific code change.
Do that for your top 10 pages by traffic and you will fix 80% of the user-felt performance problem. The rest of the work (rare interactions, edge devices, slow connections) is the long tail and is worth doing only after the trunk is clean. The 75th percentile threshold is forgiving on purpose; you do not need to hit p99 to pass, you just need to stop shipping the obvious bugs that hurt the median.
The value of Web Vitals is not the score on a dashboard. It is that they translate user pain into things you can fix. Treat them that way and they earn their keep; treat them as Lighthouse scores to maximize and you will get a 95 score on a page that still feels broken to actual users.
