1. The thresholds — and why p75 is the only number that matters
Google measures Core Web Vitals at the 75th percentile (p75) over a 28-day window using real-user data from Chrome User Experience Report (CrUX). This is the number that determines whether your URL passes — not your Lighthouse lab score.
Why p75 matters: Lighthouse runs on a clean machine with predictable network. Your real users are on 4G, lower-end Android phones, with browser extensions, in the middle of cooking dinner. The p75 of real users is 2–5× slower than your Lighthouse lab number. If your Lighthouse LCP is 2.5s, your real-user p75 is probably 5–8s.
| Metric | Good | Needs improvement | Poor |
|---|---|---|---|
| LCP (Largest Contentful Paint) | ≤ 2.5s | 2.5–4.0s | > 4.0s |
| INP (Interaction to Next Paint) | ≤ 200ms | 200–500ms | > 500ms |
| CLS (Cumulative Layout Shift) | ≤ 0.1 | 0.1–0.25 | > 0.25 |
| FCP (First Contentful Paint) | ≤ 1.8s | 1.8–3.0s | > 3.0s |
| TTFB (Time to First Byte) | ≤ 0.8s | 0.8–1.8s | > 1.8s |
INP replaced FID (First Input Delay) as a Core Web Vital. Most sites that 'passed' under FID failed under INP because INP measures every interaction's full latency, not just the first one. If you haven't re-audited since March 2024, assume you're failing.
2. LCP: the four buckets that matter (in order of leverage)
LCP is the rendering time of the largest content element above the fold. In ~80% of audits, the LCP element is either a hero image or a hero text block. We work the four buckets in this order:
Real-world impact: replacing a 480KB JPEG hero with a 92KB AVIF hero + fetchpriority='high' typically drops LCP by 1.2–1.8s on 4G. This is the single highest-ROI performance change for most sites.
- 1Server response time (TTFB). If your TTFB is over 800ms, no amount of frontend optimization fixes LCP. Move to edge rendering / CDN. Vercel, Cloudflare, Fastly. Cache static + ISR pages. Target TTFB under 200ms p75.
- 2Render-blocking resources. CSS files in the <head> block render until they download + parse. Critical CSS inline (under 14KB), defer the rest. Same for fonts: use font-display: swap and preload critical fonts. Same for any JS that's blocking — defer or async aggressively.
- 3Resource priority. Use fetchpriority='high' on the LCP image. Use rel='preload' for critical fonts and the LCP image. The browser doesn't know which image is your LCP element until it parses the layout — fetchpriority tells it explicitly.
- 4Image format + dimensions. Modern formats (AVIF, WebP) are 50–80% smaller than JPEG/PNG at equivalent quality. Specify width + height on every image to prevent layout shift. Use Next.js <Image> or equivalent responsive image library.
import Image from "next/image";
export function Hero() {
return (
<div>
<Image
src="/hero.jpg"
alt="Product screenshot"
width={1200}
height={630}
priority // sets fetchpriority="high" + preloads
sizes="(max-width: 768px) 100vw, 1200px"
// Next 13+ auto-converts to WebP/AVIF
/>
</div>
);
}3. INP — the new bottleneck (and the JavaScript reckoning)
INP measures the latency of every user interaction (click, tap, key press) over the page's lifetime, then reports the worst one. It includes input delay, processing time, and presentation delay (the next paint after the interaction).
The reason most sites fail INP: third-party scripts. Analytics, A/B testing, chat widgets, marketing automation — each one runs JavaScript on the main thread, blocking it for tens to hundreds of milliseconds. Stack 5–8 of them and your INP is 500ms+.
- Audit third-party scripts. Use the 'Network' tab + 'Coverage' tab in Chrome DevTools. Any script over 50KB that runs on every page is a candidate for removal or lazy-loading.
- Defer non-critical JS. Add the 'defer' attribute or use Next.js's next/script with strategy='lazyOnload' for analytics and chat widgets. They'll load after the main content is interactive.
- Break up long tasks. Any task over 50ms blocks the main thread. Use scheduler.yield() or setTimeout(fn, 0) to break long synchronous work into chunks.
- Use React 19's useTransition / useDeferredValue. For heavy state updates triggered by user input (filtering a list, updating a chart), wrap them in a transition. The browser keeps the UI responsive while the work happens in the background.
- Replace synchronous client-side work with server components where possible. Next.js 16 server components run on the server; the client only ships the rendered output. Less JS = better INP.
"use client";
import { useState, useTransition } from "react";
export function FilterableList({ items }: { items: Item[] }) {
const [filter, setFilter] = useState("");
const [filtered, setFiltered] = useState(items);
const [isPending, startTransition] = useTransition();
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
const next = e.target.value;
setFilter(next); // urgent: keeps input responsive
startTransition(() => {
// non-urgent: heavy filter work happens off-main-thread
setFiltered(
items.filter((it) =>
it.name.toLowerCase().includes(next.toLowerCase())
)
);
});
}
return (
<div>
<input value={filter} onChange={onChange} />
<div style={{ opacity: isPending ? 0.5 : 1 }}>
{filtered.map((it) => <Row key={it.id} item={it} />)}
</div>
</div>
);
}Most chat widgets (Intercom, Drift, HubSpot) ship 200KB+ of JavaScript on every page load. Defer them with strategy='lazyOnload' or load them on user interaction (click on a 'Chat' button). Typical INP improvement: 80–200ms.
4. CLS: how to actually hit zero (not 'good')
CLS is straightforward: every layout shift after first paint contributes a penalty. Hitting 'good' (under 0.1) is achievable for most teams. Hitting zero takes discipline but is doable.
- Set explicit dimensions on every image and video. width + height attributes on <img> elements, or aspect-ratio CSS property. Without them, the browser has to guess at layout, then re-flow when the image loads.
- Reserve space for ads, embeds, iframes. Use min-height with the expected dimensions. Update the actual dimensions only after the resource loads.
- Avoid injecting content above existing content. Cookie banners, announcement bars, A/B test variations — all of these typically push content down. Render them at fixed positions (bottom-aligned) or as overlays.
- Pre-load fonts to prevent FOIT (Flash of Invisible Text) and FOUT (Flash of Unstyled Text). Use font-display: optional or font-display: swap with size-adjust to match metrics.
/* Old way: explicit width + height attributes */
<img src="thumb.jpg" width="400" height="300" alt="..." />
/* New way: CSS aspect-ratio (more flexible for responsive layouts) */
.thumb {
aspect-ratio: 4 / 3;
width: 100%;
object-fit: cover;
}
/* Font metric override (matches Google's recommendation) */
@font-face {
font-family: "Inter";
src: url("/fonts/Inter.woff2") format("woff2");
font-display: swap;
size-adjust: 105%;
ascent-override: 90%;
descent-override: 22%;
line-gap-override: 0%;
}5. Measurement: CrUX (real users) vs lab data
Google ranks based on CrUX — Chrome User Experience Report — which is real-user data aggregated over 28 days. You cannot 'fool' CrUX with a one-time Lighthouse run. The tools you should be using:
- PageSpeed Insights — gives you both lab (Lighthouse) and field (CrUX) data for any URL. Field data is what Google ranks on.
- Search Console > Core Web Vitals report — your own site's CrUX data, segmented by URL pattern.
- Vercel Speed Insights / Cloudflare Web Analytics — real-user monitoring on your own site, with per-deploy comparison.
- Lighthouse CI — automated lab testing in your deploy pipeline. Catches regressions before they ship.
- Chrome DevTools Performance panel — for diving into specific INP regressions on specific user journeys.
CrUX data takes 28 days to fully reflect changes. If you ship a fix today, Google's ranking signal won't update for 4 weeks. Plan accordingly when measuring impact.
6. Framework-specific patterns (Next.js + React 19)
- Server Components by default. Client components ('use client') ship JavaScript; server components don't. Audit your client components — many can be moved to the server.
- Streaming. Use Suspense + loading.tsx to stream the page progressively. The user sees the first paint before slow data has loaded.
- Partial Prerendering (PPR). Next.js 16's PPR generates a static shell at build time and streams dynamic content into it. LCP for the static parts is sub-second.
- Edge runtime for SSR. Move SSR routes to the edge runtime where possible. Cold start times drop from ~800ms to ~50ms.
- Image optimization. Use the next/image component, not raw <img>. It auto-converts to WebP/AVIF and emits responsive srcset.
- Font optimization. Use next/font for self-hosted Google Fonts. It eliminates external font requests and inlines metrics.
import { Suspense } from "react";
export default function ProductPage({ params }: { params: { id: string } }) {
return (
<>
{/* Static shell — pre-rendered at build, instant */}
<Header />
<ProductSkeleton />
{/* Slow data — streamed in when ready */}
<Suspense fallback={<ReviewsLoading />}>
<Reviews productId={params.id} />
</Suspense>
<Suspense fallback={<RecommendationsLoading />}>
<Recommendations productId={params.id} />
</Suspense>
</>
);
}7. What does NOT move rankings (despite popular belief)
- Going from 'good' to 'great' on a metric you already pass. The threshold is binary: pass or fail. There's no extra ranking lift for a 0.5s LCP vs a 2.0s LCP. Optimize past 'good' for UX, not for SEO.
- Optimizing TTFB below 200ms. TTFB matters as a CWV input but isn't a direct ranking factor. Going from 200ms to 50ms TTFB doesn't move rankings.
- Lighthouse score. Lighthouse is lab data. Google ranks on CrUX field data. Some sites with Lighthouse scores of 95 fail CWV, and some sites with Lighthouse 70 pass. Field data wins.
- Mobile-only optimization. Google evaluates desktop AND mobile CrUX separately. Both must pass for both ranking signals.
- FID (anymore). FID was deprecated March 2024. INP replaced it. If your audit tool is still measuring FID, update it.