Overview
Core Web Vitals are Google's primary user-experience metrics. They directly affect SEO rankings and user retention.
The Three Metrics
| Metric | Full Name | Good | Needs Work | Poor | |--------|-----------|------|------------|------| | LCP | Largest Contentful Paint | < 2.5s | 2.5-4s | > 4s | | INP | Interaction to Next Paint | < 200ms | 200-500ms | > 500ms | | CLS | Cumulative Layout Shift | < 0.1 | 0.1-0.25 | > 0.25 |
LCP = how fast the main content appears. INP = how responsive the page is to clicks/taps/keys. CLS = how much content shifts unexpectedly during load.
Diagnosis Flow
1. Run PageSpeed Insights (pagespeed.web.dev)
→ Identifies which metric(s) are failing
2. Run Chrome DevTools Performance tab
→ See the exact LCP element, INP interaction, or CLS source
3. Fix the root cause (not symptoms)
4. Re-measure — CWV changes take ~28 days to reflect in Search Console
LCP Optimization
The LCP element is almost always: hero image, above-the-fold heading, or video poster.
<!-- Priority: preload LCP image -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high" />
// Next.js Image component — LCP hero
<Image
src="/hero.webp"
alt="Hero"
width={1200}
height={600}
priority // Removes lazy loading for LCP element
fetchPriority="high"
sizes="(max-width: 768px) 100vw, 1200px"
/>
LCP root causes and fixes:
| Root Cause | Fix |
|------------|-----|
| Large unoptimized image | Convert to WebP/AVIF, compress, use <Image> |
| Image not preloaded | Add priority or <link rel="preload"> |
| Render-blocking resources | Defer non-critical JS, inline critical CSS |
| Slow TTFB | CDN, edge caching, reduce server response time |
| No resource hints | Add dns-prefetch and preconnect for 3rd-party origins |
<!-- Preconnect to critical third-party origins -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
INP Optimization
INP replaced FID in March 2024. It measures the worst-case interaction delay, not just the first.
// Break up long tasks with scheduler.yield()
async function processLargeList(items: Item[]) {
for (let i = 0; i < items.length; i++) {
processItem(items[i]);
if (i % 50 === 0) {
await scheduler.yield(); // Yield to browser between chunks
}
}
}
// Defer non-critical work
function handleClick() {
updateUI(); // Critical — do immediately
setTimeout(() => { // Defer analytics/logging
logEvent("click");
}, 0);
}
INP root causes and fixes:
| Root Cause | Fix |
|------------|-----|
| Long tasks (> 50ms) | Break with scheduler.yield() or setTimeout |
| Hydration jank | Reduce JS bundle, use React useTransition |
| Synchronous 3rd-party scripts | Load async/defer or move to Web Worker |
| Heavy event handlers | Debounce, throttle, or move work off main thread |
CLS Optimization
CLS is caused by content shifting after initial render — images without dimensions, dynamically injected content, web fonts swapping.
/* Reserve space for images */
img, video {
aspect-ratio: attr(width) / attr(height); /* Modern approach */
}
/* Font swap without layout shift */
@font-face {
font-family: "Inter";
font-display: optional; /* "swap" causes CLS; "optional" doesn't */
}
/* Reserve space for dynamic content */
.ad-slot {
min-height: 250px; /* Known ad size */
}
// Next.js Image — always specify width/height
<Image src="/photo.jpg" alt="" width={800} height={600} />
// Skeleton placeholder — prevents shift
{isLoading ? <Skeleton className="h-48 w-full" /> : <Content />}
Quick Wins Checklist
- [ ] LCP element has
priorityorfetchpriority="high" - [ ] All images have explicit
widthandheight - [ ] Fonts use
font-display: optional(orswapwith explicit dimensions) - [ ] No layout shifts from injected ads/embeds (reserved space)
- [ ] No long tasks > 50ms in click handlers
- [ ] Third-party scripts loaded with
asyncordefer - [ ] Critical CSS inlined, non-critical deferred
- [ ] TTFB < 600ms (CDN, caching, edge functions)
Measurement Tools
| Tool | Best For | |------|---------| | PageSpeed Insights | Lab + field data, actionable recommendations | | Chrome DevTools Performance | Deep LCP/INP diagnosis, flame charts | | Search Console Core Web Vitals | Real user data over 28-day window | | web-vitals npm package | Real user monitoring in production |
// src/lib/vitals.ts — Report to analytics
import { onLCP, onINP, onCLS } from "web-vitals";
onLCP(metric => sendToAnalytics("LCP", metric.value));
onINP(metric => sendToAnalytics("INP", metric.value));
onCLS(metric => sendToAnalytics("CLS", metric.value));