Google introduced Core Web VitalsWhat is core web vitals?Three Google-defined metrics (loading speed, interactivity, visual stability) that measure real-user experience and affect search rankings. as a standardized way to measure real-world user experience. Instead of tracking dozens of synthetic metrics, you focus on three signals: how fast it loads, how quickly it responds, and whether the layout stays stable.
The three metrics at a glance
| Metric | Measures | Good | Needs improvement | Poor |
|---|---|---|---|---|
| LCP | Loading speed | < 2.5s | 2.5s, 4s | > 4s |
| INP | Responsiveness | < 200ms | 200ms, 500ms | > 500ms |
| CLS | Visual stability | < 0.1 | 0.1, 0.25 | > 0.25 |
LCPWhat is lcp?Largest Contentful Paint - measures how long it takes for the biggest visible element on the page to finish loading, with a target under 2.5 seconds., Largest Contentful Paint
LCP measures the time until the largest visible element is fully rendered, usually a hero image, heading, or large text block. The most impactful fix is almost always image optimization.
<!-- Bad: large, unoptimized image loaded without priority -->
<img src="hero-5mb.jpg" />
<!-- Good: optimized, responsive image with high fetch priority -->
<img
src="hero-small.webp"
srcset="
hero-small.webp 400w,
hero-medium.webp 800w,
hero-large.webp 1200w
"
sizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
loading="eager"
fetchpriority="high"
width="1200"
height="600"
/>fetchpriority="high" tells the browser to load this resource before other images. Use it only on your LCP element, using it everywhere cancels out the benefit.Common LCP killers
| Cause | How to fix it |
|---|---|
| Unoptimized hero image | Compress, convert to WebP/AVIF, add srcset |
| Render-blocking CSS/JS | Inline critical CSS, defer non-critical |
| Slow server response (TTFB) | Use a CDN, optimize server code |
| Client-side rendering | Pre-render or SSR the above-fold content |
| No resource prioritization | Add fetchpriority="high" to LCP element |
INPWhat is inp?Interaction to Next Paint - a Core Web Vitals metric measuring how quickly the page responds visually to user interactions., Interaction to Next Paint
INP replaced FID in March 2024. While FID only measured the first interaction's delay, INP tracks all interactions throughout the page lifecycle and reports the worst one. An interaction is a click, tap, or key press, INP measures the time from input to visual response.
INP is almost always caused by JavaScript blocking the main thread. Code splittingWhat is code splitting?Breaking your application into smaller JavaScript chunks that load on demand so users only download the code needed for the page they're viewing. is your primary weapon:
// Bad: loading everything up front blocks the main thread
import HeavyChart from './HeavyChart';
import ComplexTable from './ComplexTable';
import PDFViewer from './PDFViewer';
function Dashboard() {
return (
<div>
<HeavyChart /> {/* 200KB added to initial bundle */}
<ComplexTable /> {/* 150KB added to initial bundle */}
<PDFViewer /> {/* 300KB added to initial bundle */}
</div>
);
}
// Good: lazy load heavy components
import { lazy, Suspense } from 'react';
const HeavyChart = lazy(() => import('./HeavyChart'));
const ComplexTable = lazy(() => import('./ComplexTable'));
const PDFViewer = lazy(() => import('./PDFViewer'));
function Dashboard() {
return (
<div>
<Suspense fallback={<div>Loading chart...</div>}>
<HeavyChart />
</Suspense>
<Suspense fallback={<div>Loading table...</div>}>
<ComplexTable />
</Suspense>
<Suspense fallback={<div>Loading viewer...</div>}>
<PDFViewer />
</Suspense>
</div>
);
}Diagnosing INP issues
| Phase | What happens | How to fix |
|---|---|---|
| Input delay | JS is already running when user clicks | Reduce long tasks, code-split |
| Processing time | Event handler takes too long | Optimize handler, debounce, use web workers |
| Presentation delay | Browser takes too long to paint | Reduce DOM size, simplify CSS |
CLSWhat is cls?Cumulative Layout Shift - measures how much the page layout moves around unexpectedly while loading, with a target score under 0.1., Cumulative Layout Shift
CLS measures unexpected visual instability, content jumping around as the page loads. The most common cause is images without explicit dimensions.
<!-- Bad: browser doesn't know this image is 600px tall -->
<img src="photo.jpg" />
<!-- Good: browser reserves space before the image loads -->
<img src="photo.jpg" width="800" height="600" />
<!-- Also good: CSS aspect-ratio prevents shift -->
<div style="aspect-ratio: 4/3; overflow: hidden;">
<img src="photo.jpg" style="width: 100%; height: auto;" />
</div>Fonts are another common CLS culprit. When the browser swaps a fallback font for a web font, text reflowing causes a visible shift.
/* Use font-display: optional to prevent layout shift from fonts */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: optional; /* No shift - if font isn't cached, skip it */
}width and height on images. Every <img> tag AI produces will cause CLS unless you add dimensions.Other common CLS causes
| Cause | Fix |
|---|---|
| Images without dimensions | Add width and height attributes |
| Ads or embeds without reserved space | Use aspect-ratio containers or min-height |
| Dynamically injected content | Reserve space with skeleton placeholders |
| Web font swap | Use font-display: optional or swap with size-adjust |
Measuring Core Web VitalsWhat is core web vitals?Three Google-defined metrics (loading speed, interactivity, visual stability) that measure real-user experience and affect search rankings.
| Tool | Type | Best for |
|---|---|---|
| Chrome DevTools / Lighthouse | Lab | Development and debugging |
| PageSpeed Insights | Lab + Field | Quick check on any URL |
| Chrome UX Report (CrUX) | Field | Real-world data from Chrome users |
| web-vitals library | Field | Monitoring in your own production analytics |
Lab data tells you what could happen. Field data tells you what is actually happening. Field data is what Google uses for ranking.
import { onCLS, onINP, onLCP } from 'web-vitals';
function sendToAnalytics({ name, value, id }) {
// Send to your analytics service
fetch('/api/vitals', {
method: 'POST',
body: JSON.stringify({ name, value, id }),
});
}
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);Where to start
If you have poor scores across all three metrics, fix them in this order:
- CLSWhat is cls?Cumulative Layout Shift - measures how much the page layout moves around unexpectedly while loading, with a target score under 0.1. first: usually a quick fix, just add image dimensions and font-display
- LCPWhat is lcp?Largest Contentful Paint - measures how long it takes for the biggest visible element on the page to finish loading, with a target under 2.5 seconds. second: optimize your hero image, add fetchpriority, check server response time
- INPWhat is inp?Interaction to Next Paint - a Core Web Vitals metric measuring how quickly the page responds visually to user interactions. last: code splittingWhat is code splitting?Breaking your application into smaller JavaScript chunks that load on demand so users only download the code needed for the page they're viewing. takes more thought but has the biggest impact on interactivity
// Complete example: Optimizing for Core Web Vitals
// 1. LCP optimization, responsive hero with priority
function HeroSection() {
return (
<section>
<img
src="/hero.webp"
srcSet="/hero-400.webp 400w, /hero-800.webp 800w, /hero-1200.webp 1200w"
sizes="100vw"
width="1200"
height="600"
alt="Hero image"
fetchpriority="high"
loading="eager"
/>
<h1>Welcome to Our Site</h1>
</section>
);
}
// 2. INP optimization with code splitting
import { lazy, Suspense } from 'react';
const HeavyComponent = lazy(() => import('./HeavyComponent'));
function App() {
return (
<div>
<header>Navigation</header>
<main>Main content</main>
<Suspense fallback={<div>Loading...</div>}>
<HeavyComponent />
</Suspense>
</div>
);
}
// 3. CLS optimization, always set dimensions
function ProductImage({ src, alt }) {
return (
<div style={{ aspectRatio: '16/9', overflow: 'hidden' }}>
<img
src={src}
alt={alt}
width="800"
height="450"
loading="lazy"
style={{ width: '100%', height: 'auto' }}
/>
</div>
);
}
// 4. Monitoring in production
import { onCLS, onINP, onLCP } from 'web-vitals';
function reportWebVitals(metric) {
if (metric.rating === 'poor') {
console.warn(`Poor ${metric.name}:`, metric.value);
}
}
onCLS(reportWebVitals);
onINP(reportWebVitals);
onLCP(reportWebVitals);