Overview

Core Web Vitals pass when LCP is under 2.5 s, INP is under 200 ms, and CLS is under 0.1 at the 75th percentile of real users. This guide takes a site that fails one or more, measures the gap with field data, applies the standard fixes per metric, and re-measures. The thresholds and rationale live in core-web-vitals; this is the procedure.

Prerequisites

  • Site is publicly reachable and has been live long enough for CrUX to have data (about 28 days of traffic).
  • Access to the source for the page template (HTML, CSS, JS).
  • A way to deploy and re-measure on a short loop. Slow deploys turn this into a week-long exercise.

Steps

1. Measure the starting point

Use both lab and field data. Lab tools are fast; field data is what Google ranks against.

# Field data via the public CrUX API.
curl -X POST 'https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://yourdomain.com/", "formFactor":"PHONE"}'

For finer-grained measurement, install the web-vitals JS library and forward metrics to your analytics. Watch the 75th percentile, not the average.

Lab pass: open https://pagespeed.web.dev and run the URL. Record the three numbers before changing anything.

2. Fix LCP: ship the hero element fast

LCP is almost always the hero image or H1. Preload it and serve a modern format.

<link rel="preload" as="image" href="/hero.avif" fetchpriority="high" />
<!-- in the body: -->
<img src="/hero.avif" width="1200" height="630" fetchpriority="high" alt="..." />
  • Compress the hero to 50 to 150 KB. AVIF first, WebP fallback, JPEG as the last resort.
  • Self-host fonts and set font-display: swap.
  • Render the hero in server HTML, not in a client-only React tree.
  • Put a CDN in front (see cloudflare) so international users get the same LCP as local ones.

3. Fix CLS: reserve every box before content arrives

Layout shifts come from elements that arrive after first paint. Reserve their boxes.

<!-- Every image declares dimensions or aspect-ratio. -->
<img src="/photo.jpg" width="800" height="600" alt="..." />
 
<!-- Ad slots reserve fixed height. -->
<div class="ad-slot" style="min-height: 250px;"></div>
.media { aspect-ratio: 16 / 9; }
/* Animate transform and opacity only. */
.fade { transition: opacity 200ms, transform 200ms; }

Web fonts also shift. Match the fallback metrics with size-adjust, ascent-override, and descent-override in @font-face.

4. Fix INP: break long JavaScript tasks

INP measures the worst interaction on the page, not just the first. Every click and key press counts.

// Bad: 250 ms of synchronous work blocks the next paint.
function processAll(items) {
  for (const item of items) heavyWork(item)
}
 
// Better: yield to the scheduler between chunks.
async function processAll(items) {
  for (const item of items) {
    heavyWork(item)
    await scheduler.yield() // or: await new Promise(r => setTimeout(r, 0))
  }
}
  • Lazy-load third-party scripts (analytics, chat, A/B testing). Use async or defer; better, gate behind an interaction.
  • Use content-visibility: auto on off-screen sections so the browser skips layout for what is not visible.
  • Avoid hydrating the whole page on load. Astro islands and React Server Components keep INP under 200 ms by default.

5. Re-measure after each fix

After every deploy, re-run the lab test and watch the in-page web-vitals metric. Field data via CrUX takes 28 days to reflect changes; do not wait on it during the fix loop.

Verify it worked

Pass conditions at the 75th percentile of real users:

# 1. Lab: PageSpeed Insights mobile run shows three green checks for the URL.
# Open: https://pagespeed.web.dev/analysis?url=https%3A%2F%2Fyourdomain.com
 
# 2. Field: CrUX reports "good" at p75 for all three.
curl -X POST 'https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=YOUR_KEY' \
  -H 'Content-Type: application/json' \
  -d '{"url":"https://yourdomain.com/", "formFactor":"PHONE"}' \
  | jq '.record.metrics | {lcp: .largest_contentful_paint, inp: .interaction_to_next_paint, cls: .cumulative_layout_shift}'
 
# 3. Search Console > Core Web Vitals report turns "Good" within 28 days.

Common errors

  • LCP drops in the lab but not in the field. The lab uses a fast network; real users on 4G still hit the slow image. Re-check on a throttled connection.
  • CLS regresses after a redesign. A new component shipped without dimensions. Grep for <img and <iframe with no width or aspect-ratio.
  • INP is fine on desktop but fails on mobile. Hydration cost scales with CPU. Move work off the main thread, or split the bundle.
  • The web-vitals JS reports nothing. The library only records interactions that happen; metrics arrive over the session, not at load.
  • PageSpeed Insights shows different numbers than the field data. PSI uses the same CrUX dataset for the field section; the lab section is one synthetic run and will not match.