What Are Core Web Vitals?
Core Web Vitals are a set of three specific metrics that Google considers essential for delivering a great user experience on the web. They measure loading performance, interactivity, and visual stability. Since June 2021, they are a direct Google search ranking factor, making them critical for both user experience and SEO.
The Three Core Web Vitals (2024+)
- LCP (Largest Contentful Paint): Measures loading — when the largest content element becomes visible. Target: under 2.5 seconds
- INP (Interaction to Next Paint): Measures responsiveness — the latency of all interactions throughout the page lifecycle. Target: under 200 milliseconds
- CLS (Cumulative Layout Shift): Measures visual stability — the sum of unexpected layout shifts. Target: under 0.1
Largest Contentful Paint (LCP)
LCP measures when the largest content element in the viewport becomes visible. This is typically a hero image, a large block of text, or a video thumbnail. LCP is the most important loading metric because it reflects when the user perceives the main content has loaded.
// Monitoring LCP with detailed attribution
function observeLCP(): void {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1] as any;
console.log('LCP Details:', {
value: lastEntry.startTime,
element: lastEntry.element?.tagName,
url: lastEntry.url,
size: lastEntry.size,
loadTime: lastEntry.loadTime,
renderTime: lastEntry.renderTime,
});
// Breakdown of LCP sub-parts
const navEntry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
const ttfb = navEntry.responseStart - navEntry.requestStart;
const resourceLoadDelay = lastEntry.startTime - ttfb;
console.log('LCP Breakdown:', {
ttfb: `${ttfb.toFixed(0)}ms`,
resourceLoadDelay: `${resourceLoadDelay.toFixed(0)}ms`,
totalLCP: `${lastEntry.startTime.toFixed(0)}ms`,
});
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
}
// Common LCP optimizations in Next.js
import Image from 'next/image';
// GOOD: Priority loading for LCP image
function HeroSection() {
return (
<Image
src="/hero-banner.webp"
alt="Hero"
width={1200}
height={600}
priority // Adds preload link, disables lazy loading
sizes="100vw"
/>
);
}LCP Optimization Checklist
- Reduce TTFB: Use a CDN, optimize server response, enable caching
- Eliminate render-blocking resources: Inline critical CSS, defer non-critical JS
- Preload the LCP resource: Use <link rel="preload"> for hero images or fonts
- Optimize images: Use WebP/AVIF, responsive sizes, proper compression
- Use priority hints: fetchpriority="high" on LCP images
- Avoid client-side rendering: SSR or SSG the LCP content
Interaction to Next Paint (INP)
INP replaced First Input Delay (FID) as a Core Web Vital in March 2024. While FID only measured the first interaction, INP captures the responsiveness of all interactions throughout the entire page lifecycle. INP is the worst-case interaction latency (approximately the 98th percentile).
// Measuring INP with the web-vitals library
import { onINP } from 'web-vitals';
onINP((metric) => {
console.log('INP:', {
value: metric.value,
rating: metric.rating, // 'good', 'needs-improvement', 'poor'
entries: metric.entries.map((entry) => ({
name: entry.name,
duration: entry.duration,
processingStart: entry.processingStart,
processingEnd: entry.processingEnd,
startTime: entry.startTime,
// Break down the interaction
inputDelay: entry.processingStart - entry.startTime,
processingTime: entry.processingEnd - entry.processingStart,
presentationDelay: entry.duration - (entry.processingEnd - entry.startTime),
})),
});
});
// Optimizing event handlers for better INP
function OptimizedList({ items }: { items: string[] }) {
const handleClick = async (id: string) => {
// 1. Provide immediate visual feedback
const element = document.getElementById(id);
element?.classList.add('active');
// 2. Yield to let the browser paint the feedback
await new Promise(resolve => setTimeout(resolve, 0));
// 3. Do the expensive work after paint
await processExpensiveOperation(id);
};
return (
<ul>
{items.map(item => (
<li key={item} id={item} onClick={() => handleClick(item)}>
{item}
</li>
))}
</ul>
);
}
async function processExpensiveOperation(id: string): Promise<void> {
// Use requestIdleCallback or scheduler.postTask for non-urgent work
if ('scheduler' in globalThis) {
await (globalThis as any).scheduler.postTask(
() => { /* expensive computation */ },
{ priority: 'background' }
);
}
}Cumulative Layout Shift (CLS)
CLS measures the visual stability of a page by quantifying how much visible content shifts unexpectedly. Each layout shift is scored based on two factors: the impact fraction (how much of the viewport is affected) and the distance fraction (how far elements move). CLS is the sum of all unexpected shift scores.
// Debugging CLS issues
function debugCLS(): void {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
const shift = entry as any;
if (!shift.hadRecentInput) {
console.log('Layout shift detected:', {
value: shift.value,
sources: shift.sources?.map((source: any) => ({
node: source.node?.nodeName,
id: source.node?.id,
className: source.node?.className,
previousRect: source.previousRect,
currentRect: source.currentRect,
})),
});
}
}
});
observer.observe({ type: 'layout-shift', buffered: true });
}
// Preventing CLS with proper dimension reservations
// In Next.js, always specify width and height for images
import Image from 'next/image';
function StableLayout() {
return (
<div>
{/* GOOD: Dimensions reserved, no layout shift */}
<Image src="/photo.jpg" alt="Photo" width={800} height={600} />
{/* GOOD: Aspect ratio container for dynamic content */}
<div style={{ aspectRatio: '16/9', position: 'relative' }}>
<Image src="/video-thumb.jpg" alt="Video" fill sizes="100vw" />
</div>
{/* GOOD: min-height for dynamic content areas */}
<div style={{ minHeight: '200px' }}>
{/* Content loaded asynchronously */}
</div>
</div>
);
}Common CLS Causes and Fixes
- Images without dimensions: Always specify width/height or use aspect-ratio
- Ads and embeds: Reserve space with min-height containers
- Web fonts (FOUT/FOIT): Use font-display: swap with size-adjust or preload fonts
- Dynamic content insertion: Insert content below the fold or use CSS containment
- Animations: Use transform instead of top/left/width/height changes
Measuring Core Web Vitals in Production
// Complete RUM (Real User Monitoring) setup with web-vitals
import { onLCP, onINP, onCLS, onFCP, onTTFB, type Metric } from 'web-vitals';
interface VitalMetric {
name: string;
value: number;
rating: string;
navigationType: string;
url: string;
}
function sendToAnalytics(metric: Metric): void {
const body: VitalMetric = {
name: metric.name,
value: metric.value,
rating: metric.rating,
navigationType: metric.navigationType,
url: window.location.href,
};
// Use sendBeacon for reliability (survives page unload)
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/vitals', JSON.stringify(body));
} else {
fetch('/api/vitals', {
method: 'POST',
body: JSON.stringify(body),
keepalive: true,
});
}
}
// Initialize all observers
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);
// Next.js API route to receive vitals
// app/api/vitals/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const vital = await request.json();
// Store in your database or analytics service
console.log('Web Vital received:', vital);
// Forward to your analytics pipeline
// await db.insert('web_vitals', vital);
return NextResponse.json({ status: 'ok' });
}Key Takeaways
- LCP under 2.5s: Optimize server response, preload critical resources, use efficient image formats
- INP under 200ms: Keep event handlers fast, yield to the main thread, minimize JavaScript
- CLS under 0.1: Reserve space for dynamic content, avoid inserting content above the fold
- Measure in the field: Lab tools cannot replicate real-world network and device diversity