Why Core Web Vitals Matter
Core Web Vitals are Google's quantification of user experience. They measure what users feel — how fast content appears, how stable the layout is, and how responsive the page is to input. Since 2021, they're a direct ranking signal. Since 2024, with Interaction to Next Paint (INP) replacing First Input Delay (FID), they're harder to game and more meaningful.
The three metrics are deceptively simple to define and surprisingly difficult to optimize. Most teams address symptoms (lazy-loading everything, adding loading spinners) without understanding the underlying rendering pipeline that determines each score.
- LCP (Largest Contentful Paint): Measures loading — when the main content becomes visible. Target: ≤ 2.5s
- CLS (Cumulative Layout Shift): Measures visual stability — how much elements move after rendering. Target: ≤ 0.1
- INP (Interaction to Next Paint): Measures responsiveness — delay between user input and visual update. Target: ≤ 200ms
#1 LCP: Largest Contentful Paint
LCP measures when the largest above-the-fold element finishes rendering. The LCP element is usually a hero image, a heading, or a large text block. Poor LCP is almost always caused by late resource discovery — the browser doesn't know it needs the resource until too late.
Optimize the Critical Rendering Path
The four sub-phases of LCP: Time to First Byte (TTFB) → Resource Load Delay → Resource Load Duration → Element Render Delay. Each phase is an optimization opportunity:
// 1. Preload the LCP image — eliminate resource load delay
// The browser discovers <link rel="preload"> immediately,
// before it even parses the HTML body
export default function RootLayout({ children }) {
return (
<html>
<head>
{/* Preload hero image with fetchpriority */}
<link
rel="preload"
as="image"
href="/hero-banner.webp"
fetchPriority="high"
/>
{/* Preconnect to CDN for third-party resources */}
<link rel="preconnect" href="https://cdn.example.com" />
</head>
<body>{children}</body>
</html>
);
}import Image from 'next/image';
export function HeroBanner() {
return (
<section className="relative h-[600px]">
{/* priority tells Next.js to set fetchPriority="high"
and preload the image. Only use on LCP element. */}
<Image
src="/hero-banner.webp"
alt="Product showcase"
fill
priority
sizes="100vw"
className="object-cover"
/>
<div className="relative z-10 p-8">
<h1 className="text-5xl font-bold">Ship Faster</h1>
</div>
</section>
);
}
// Key LCP optimizations:
// - Use 'priority' on the LCP image (adds preload + fetchPriority)
// - Use 'sizes' to prevent loading oversized images
// - Serve WebP/AVIF via Next.js Image optimization
// - Avoid lazy-loading above-the-fold images (Next.js default)- Preloading eliminates resource discovery delay — the #1 cause of slow LCP
- Proper image sizing prevents bandwidth waste on oversized images
- Server-side rendering ensures HTML is ready before JavaScript loads
#2 CLS: Cumulative Layout Shift
CLS measures unexpected visual movement. Every time an element shifts position after the initial render, the browser calculates the distance moved × the area affected. The scores accumulate across the session in a sliding window. The most common causes: images without dimensions, dynamically injected content, and web fonts loading late.
Prevent Layout Shifts at Every Layer
// Always set explicit dimensions on media elements.
// Without width/height, the browser can't reserve space
// until the image loads — causing a layout shift.
export function ArticleCard({ article }) {
return (
<div className="overflow-hidden rounded-lg">
{/* ✅ Explicit aspect-ratio reserves space before load */}
<div className="aspect-video relative">
<Image
src={article.image}
alt={article.title}
fill
sizes="(max-width: 768px) 100vw, 33vw"
/>
</div>
{/* ✅ min-height prevents shift when content varies */}
<div className="min-h-[120px] p-4">
<h3>{article.title}</h3>
<p>{article.excerpt}</p>
</div>
</div>
);
}import { Roboto } from 'next/font/google';
// next/font eliminates font-swap CLS by:
// 1. Self-hosting the font (no network request to Google)
// 2. Using size-adjust to match fallback font metrics
// 3. Preloading the font file
const roboto = Roboto({
subsets: ['latin'],
display: 'swap',
weight: ['400', '500', '700'],
// size-adjust is calculated automatically by next/font
// to minimize CLS when swapping from fallback → web font
});
export default function Layout({ children }) {
return (
<html className={roboto.className}>
<body>{children}</body>
</html>
);
}- Always set width/height or aspect-ratio on images and videos
- Use next/font to self-host fonts with automatic size-adjust
- Reserve space for dynamic content with min-height or skeleton placeholders
- Never inject content above existing content (banners, cookie bars) — use transform instead
- Avoid document.write() and late-loading third-party scripts that inject DOM
- Explicit dimensions eliminate image-related layout shifts entirely
- Self-hosted fonts with size-adjust reduce font-swap CLS to near zero
- Skeleton placeholders reserve space for async content
#3 INP: Interaction to Next Paint
INP replaced FID in March 2024 as the responsiveness metric. While FID only measured the first interaction's input delay, INP measures every interaction throughout the page lifecycle and reports the worst one (at the 98th percentile). It includes three phases: input delay, processing time, and presentation delay.
Keep the Main Thread Responsive
import { useCallback, useTransition } from 'react';
// Use React transitions to keep interactions responsive.
// startTransition marks state updates as non-urgent,
// allowing the browser to paint between frames.
export function useFilteredProducts(products: Product[]) {
const [filtered, setFiltered] = useState(products);
const [isPending, startTransition] = useTransition();
const onFilter = useCallback((query: string) => {
// Immediate: update the input (stays responsive)
// Deferred: filter products in a transition
startTransition(() => {
const result = products.filter(p =>
p.name.toLowerCase().includes(query.toLowerCase())
);
setFiltered(result);
});
}, [products]);
return { filtered, isPending, onFilter };
}// For truly heavy work, move it off the main thread.
// Web Workers process data without blocking interactions.
const worker = new Worker(
new URL('../workers/sort.worker.ts', import.meta.url)
);
export function DataTable({ data }) {
const [sorted, setSorted] = useState(data);
const handleSort = useCallback((column: string) => {
worker.postMessage({ data, column });
worker.onmessage = (e) => setSorted(e.data);
}, [data]);
return (
<table>
<thead>
<tr>
<th onClick={() => handleSort('name')}>Name</th>
<th onClick={() => handleSort('price')}>Price</th>
</tr>
</thead>
<tbody>
{sorted.map(row => (
<tr key={row.id}>
<td>{row.name}</td>
<td>{row.price}</td>
</tr>
))}
</tbody>
</table>
);
}- Break long tasks (>50ms) into smaller chunks with scheduler.yield() or setTimeout
- Use React 19 useTransition for non-urgent state updates
- Move heavy computation to Web Workers (sorting, filtering large datasets)
- Avoid layout thrashing: batch DOM reads before DOM writes
- Minimize third-party scripts that block the main thread
- React transitions keep input fields responsive during expensive re-renders
- Web Workers prevent main-thread blocking for data processing
- Breaking long tasks allows the browser to paint between frames
Measuring in the Field
Lab tools (Lighthouse, WebPageTest) give you a controlled baseline. Field data (Chrome User Experience Report, web-vitals library) tells you what real users experience. Always use both — lab data for debugging, field data for decisions.
import { onCLS, onINP, onLCP } from 'web-vitals';
// Report field metrics to your analytics provider
function sendToAnalytics(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // 'good' | 'needs-improvement' | 'poor'
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
});
// Use sendBeacon so metrics aren't lost on page unload
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/vitals', body);
}
}
// Register observers
onLCP(sendToAnalytics);
onCLS(sendToAnalytics);
onINP(sendToAnalytics);Key Takeaways
- 1LCP is a loading metric — preload the LCP resource and eliminate discovery delays
- 2CLS is a stability metric — set explicit dimensions on all media and use next/font for fonts
- 3INP is a responsiveness metric — keep the main thread free with transitions and Web Workers
- 4Lab data (Lighthouse) is for debugging; field data (CrUX, web-vitals) is for decisions
- 5Optimize the metric sub-phases, not the symptoms — understand why each score is what it is
- 6All three thresholds: LCP ≤ 2.5s, CLS ≤ 0.1, INP ≤ 200ms