Core Web Vitals & Metrics
Core Web Vitals are Google's standardized metrics for measuring user experience. Since June 2021, they've been a ranking factor for search results. More importantly, they correlate with real user satisfaction—pages that score well retain visitors and convert better.
The three Core Web Vitals are:
| Metric | What It Measures | Good | Needs Improvement | Poor |
|---|---|---|---|---|
| LCP | Loading (main content visible) | ≤2.5s | 2.5s–4s | >4s |
| CLS | Visual stability (layout shifts) | ≤0.1 | 0.1–0.25 | >0.25 |
| INP | Responsiveness (interaction delay) | ≤200ms | 200ms–500ms | >500ms |
These thresholds apply to the 75th percentile of page loads—meaning 75% of your users should experience "good" scores.
LCP: Largest Contentful Paint
LCP measures how long it takes for the main content to become visible. It identifies the largest image, video, or text block in the viewport and reports when it finishes rendering.
What Counts as the LCP Element?
The browser considers these element types:
<img>elements<image>elements inside<svg><video>elements (poster image)- Elements with
background-imageloaded via CSS - Block-level text elements (
<h1>,<p>,<div>with text)
The "largest" is determined by visible size, not file size. A hero image typically wins, but on text-heavy pages, a heading might be the LCP element.
Diagnosing LCP Issues
Use Chrome DevTools to identify your LCP element:
- Open DevTools → Performance tab
- Record a page load
- Click "Timings" → Look for the "LCP" marker
- Hover to see which element triggered it
Or use the Web Vitals extension, which shows the LCP element directly.
Common LCP problems:
| Problem | Symptom | Solution |
|---|---|---|
| Slow server response | High TTFB (>600ms) | Optimize backend, use CDN |
| Render-blocking resources | Large CSS/JS delaying render | Inline critical CSS, defer JS |
| Slow resource load | LCP image takes too long | Preload, optimize format, use CDN |
| Client-side rendering | Content requires JS to appear | Use SSR/SSG |
Optimizing LCP
1. Optimize Server Response Time (TTFB)
Time to First Byte directly impacts LCP. Every millisecond of TTFB delays everything.
# Check TTFB with curl
curl -w "TTFB: %{time_starttransfer}s\n" -o /dev/null -s https://example.com
Improvements:
- Use a CDN to serve content from edge locations
- Implement server-side caching (see Caching Strategies)
- Optimize database queries
- Use HTTP/2 or HTTP/3
2. Preload the LCP Resource
If your LCP element is an image, tell the browser to fetch it early:
<head>
<!-- Preload hero image with high priority -->
<link
rel="preload"
href="/images/hero.webp"
as="image"
fetchpriority="high"
>
</head>
For responsive images, preload the most likely candidate:
<link
rel="preload"
href="/images/hero-800.webp"
as="image"
imagesrcset="/images/hero-400.webp 400w, /images/hero-800.webp 800w, /images/hero-1200.webp 1200w"
imagesizes="(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"
>
3. Eliminate Render-Blocking Resources
CSS blocks rendering. JavaScript (without async/defer) blocks HTML parsing.
<!-- ❌ Blocking -->
<link rel="stylesheet" href="/styles/all.css">
<script src="/app.js"></script>
<!-- ✅ Non-blocking -->
<style>/* Critical CSS inline */</style>
<link rel="preload" href="/styles/below-fold.css" as="style"
onload="this.rel='stylesheet'">
<script src="/app.js" defer></script>
Extract critical CSS (above-the-fold styles) with tools like critical:
npx critical https://example.com --inline --minify > index.html
4. Avoid Client-Side Rendering for LCP Content
If your LCP element requires JavaScript to render, you're adding unnecessary delay:
// ❌ LCP content rendered client-side
function HeroSection() {
const [hero, setHero] = useState(null);
useEffect(() => {
fetch('/api/hero').then(r => r.json()).then(setHero);
}, []);
return hero ? <img src={hero.image} /> : null;
}
// ✅ LCP content server-rendered
// In Next.js, Astro, or similar frameworks
export async function getStaticProps() {
const hero = await fetchHero();
return { props: { hero } };
}
function HeroSection({ hero }) {
return <img src={hero.image} />;
}
Use SSR or SSG for pages where LCP matters (most pages). See the Next.js or Astro articles for implementation details.
5. Optimize Image Size and Format
A 2MB JPEG hero image will kill your LCP score:
<!-- ❌ Unoptimized -->
<img src="/hero.jpg" alt="Hero">
<!-- ✅ Optimized with modern formats and sizing -->
<picture>
<source srcset="/hero.avif" type="image/avif">
<source srcset="/hero.webp" type="image/webp">
<img
src="/hero.jpg"
alt="Hero"
width="1200"
height="600"
fetchpriority="high"
decoding="async"
>
</picture>
See the Asset Optimization article for image format details.
CLS: Cumulative Layout Shift
CLS measures visual stability—how much the page layout shifts unexpectedly while loading. A score of 0 means nothing moved; a score above 0.25 means the page is "jumpy" and frustrating to use.
How CLS Is Calculated
CLS sums up all individual layout shift scores during the page's lifetime. Each shift is calculated as:
Layout Shift Score = Impact Fraction × Distance Fraction
- Impact Fraction: Percentage of viewport affected by the shift
- Distance Fraction: How far elements moved (as percentage of viewport)
A shift affecting 50% of the viewport, moving content by 25% of viewport height = 0.5 × 0.25 = 0.125 CLS
Common CLS Causes and Fixes
1. Images Without Dimensions
When an image loads, the browser doesn't know its size until it downloads. Without explicit dimensions, layout shifts when the image appears.
<!-- ❌ Causes layout shift -->
<img src="/photo.jpg" alt="Photo">
<!-- ✅ No layout shift -->
<img src="/photo.jpg" alt="Photo" width="800" height="600">
For responsive images, use CSS aspect ratio:
.hero-image {
aspect-ratio: 16 / 9;
width: 100%;
height: auto;
}
Or the aspect-ratio attribute in modern browsers:
<img
src="/photo.jpg"
alt="Photo"
style="aspect-ratio: 16/9; width: 100%; height: auto;"
>
2. Ads, Embeds, and Iframes
Third-party content often loads late and shifts content around it.
<!-- ❌ Ad shifts content when it loads -->
<div class="sidebar">
<div id="ad-slot"></div>
<nav>...</nav>
</div>
<!-- ✅ Reserve space for the ad -->
<div class="sidebar">
<div id="ad-slot" style="min-height: 250px;">
<!-- Ad loads here -->
</div>
<nav>...</nav>
</div>
For embeds with unknown sizes, use a container with minimum dimensions:
.embed-container {
min-height: 400px;
background: #f0f0f0; /* Visual placeholder */
}
3. Web Fonts Causing FOUT
When custom fonts load, they can cause text to reflow (Flash of Unstyled Text—FOUT), shifting layout.
/* ❌ Default: invisible text, then swap (FOIT + shift) */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2');
}
/* ✅ Optional: Use fallback if font doesn't load quickly */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2');
font-display: optional;
}
font-display options for CLS:
| Value | Behavior | CLS Impact |
|---|---|---|
swap |
Show fallback immediately, swap when loaded | Causes shift |
optional |
Show fallback, only swap if loaded in ~100ms | Minimal shift |
fallback |
Short invisible period, then fallback, limited swap window | Moderate shift |
For zero CLS, use optional or match your fallback font metrics closely:
/* Size-adjusted fallback to minimize reflow */
@font-face {
font-family: 'CustomFont Fallback';
src: local('Arial');
size-adjust: 105%;
ascent-override: 95%;
descent-override: 22%;
line-gap-override: 0%;
}
body {
font-family: 'CustomFont', 'CustomFont Fallback', sans-serif;
}
Tools like Fontaine can generate these metrics automatically.
4. Dynamically Injected Content
Content added after initial render (banners, modals, cookie notices) shifts existing content.
// ❌ Banner shifts page content
function App() {
const [showBanner, setShowBanner] = useState(false);
useEffect(() => {
checkCookieConsent().then(needed => setShowBanner(needed));
}, []);
return (
<>
{showBanner && <CookieBanner />} {/* Shifts everything below */}
<Header />
<Main />
</>
);
}
// ✅ Reserve space or use overlay positioning
function App() {
return (
<>
<div style={{ minHeight: '60px' }}> {/* Reserved space */}
{showBanner && <CookieBanner />}
</div>
<Header />
<Main />
</>
);
}
// ✅ Or use fixed/overlay positioning (no shift)
.cookie-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
}
5. Animations That Trigger Layout
Animating properties like width, height, top, left, or margin causes layout recalculation and can trigger CLS.
/* ❌ Triggers layout shifts */
.expanding-box {
transition: height 0.3s;
}
/* ✅ Use transform (compositor-only, no layout) */
.expanding-box {
transition: transform 0.3s;
}
.expanding-box.expanded {
transform: scaleY(1.5);
}
The only properties that don't trigger layout are transform and opacity. Animate these exclusively.
Debugging CLS
Use the Layout Shift Regions feature in Chrome DevTools:
- DevTools → Rendering (three-dot menu → More tools → Rendering)
- Enable "Layout Shift Regions"
- Reload the page—blue rectangles highlight shifting elements
Or use the Performance panel to see individual layout shifts in the timeline.
INP: Interaction to Next Paint
INP replaced First Input Delay (FID) as a Core Web Vital in March 2024. While FID only measured the first interaction, INP measures responsiveness throughout the entire page session.
What INP Measures
INP tracks the time from user interaction (click, tap, keypress) to when the browser can paint the next frame showing visual feedback.
INP = Input Delay + Processing Time + Presentation Delay
Where:
- Input Delay: Time until event handler runs (main thread busy)
- Processing Time: Time running the event handler
- Presentation Delay: Time for browser to render result
The reported INP is typically the worst (highest latency) interaction during the session, with statistical adjustments for sessions with many interactions.
Common INP Problems
1. Long Tasks Blocking Main Thread
JavaScript tasks over 50ms are "long tasks" that block the main thread, delaying input response.
// ❌ Long task: blocks main thread for 200ms
function processLargeDataset(data) {
for (let i = 0; i < data.length; i++) {
expensiveOperation(data[i]); // Takes 0.2ms each, 1000 items = 200ms
}
}
// ✅ Chunked: yields to main thread periodically
async function processLargeDataset(data) {
const CHUNK_SIZE = 50;
for (let i = 0; i < data.length; i += CHUNK_SIZE) {
const chunk = data.slice(i, i + CHUNK_SIZE);
chunk.forEach(item => expensiveOperation(item));
// Yield to main thread after each chunk
await scheduler.yield?.() ?? new Promise(r => setTimeout(r, 0));
}
}
The scheduler.yield() API (Chrome 115+) yields control back to the browser, allowing pending interactions to be processed.
2. Expensive Event Handlers
// ❌ Heavy handler runs synchronously
function SearchBox() {
const handleInput = (e) => {
const results = searchDatabase(e.target.value); // 100ms operation
setResults(results);
};
return <input onChange={handleInput} />;
}
// ✅ Debounce and use web worker for heavy computation
function SearchBox() {
const handleInput = useDebouncedCallback((value) => {
searchWorker.postMessage(value);
}, 150);
useEffect(() => {
searchWorker.onmessage = (e) => setResults(e.data);
}, []);
return <input onChange={(e) => handleInput(e.target.value)} />;
}
3. Hydration Blocking Interactions
In SSR frameworks, the page is visible before JavaScript loads, but buttons don't work until hydration completes.
// ❌ User clicks button, nothing happens (JS still loading)
<button onClick={handleSubmit}>Submit</button>
// ✅ Show loading state until interactive
function SubmitButton({ isHydrated }) {
return (
<button
onClick={handleSubmit}
disabled={!isHydrated}
aria-busy={!isHydrated}
>
{isHydrated ? 'Submit' : 'Loading...'}
</button>
);
}
Or use progressive hydration patterns (available in Astro, Next.js 13+ with React Server Components).
4. Third-Party Scripts
Analytics, chat widgets, and advertising scripts often run expensive code on the main thread.
<!-- ❌ Loads and executes immediately, blocking main thread -->
<script src="https://analytics.example.com/heavy-analytics.js"></script>
<!-- ✅ Load after page is interactive -->
<script>
window.addEventListener('load', () => {
const script = document.createElement('script');
script.src = 'https://analytics.example.com/heavy-analytics.js';
document.body.appendChild(script);
});
</script>
Or use requestIdleCallback for lowest priority loading:
requestIdleCallback(() => {
loadAnalytics();
}, { timeout: 3000 });
Optimizing INP
Use requestAnimationFrame for Visual Updates
Ensure UI updates happen at the right time in the render cycle:
// ❌ May cause jank if called at wrong time
function updateProgress(value) {
progressBar.style.width = `${value}%`;
}
// ✅ Synchronized with browser paint cycle
function updateProgress(value) {
requestAnimationFrame(() => {
progressBar.style.width = `${value}%`;
});
}
Use CSS content-visibility for Off-Screen Content
Skip rendering work for content not in the viewport:
.comments-section {
content-visibility: auto;
contain-intrinsic-size: 0 500px; /* Estimated height */
}
This dramatically reduces main thread work, especially on long pages.
Optimize React/Vue Rendering
// ❌ Entire list re-renders on any state change
function ItemList({ items, onSelect }) {
return items.map(item => (
<Item key={item.id} item={item} onSelect={onSelect} />
));
}
// ✅ Memoize to prevent unnecessary re-renders
const Item = React.memo(function Item({ item, onSelect }) {
return (
<div onClick={() => onSelect(item.id)}>
{item.name}
</div>
);
});
Other Important Metrics
Beyond Core Web Vitals, these metrics provide additional insight:
Time to First Byte (TTFB)
Time from request to first byte of response. Target: <800ms for first visit, <100ms for cached.
// Measure TTFB in JavaScript
const [entry] = performance.getEntriesByType('navigation');
console.log('TTFB:', entry.responseStart - entry.requestStart);
First Contentful Paint (FCP)
When the first text or image appears. Should be <1.8s for "good" score.
Total Blocking Time (TBT)
Lab metric summing all long task time (portion over 50ms) between FCP and Time to Interactive. Correlates with INP but is measurable in lab tests.
Speed Index
Visual completeness over time—how quickly content is painted to the screen. Lower is better; target <3.4s.
Measuring Core Web Vitals
In the Lab (Development)
// Using web-vitals library
import { onLCP, onCLS, onINP } from 'web-vitals';
onLCP(console.log);
onCLS(console.log);
onINP(console.log);
Install with:
pnpm add web-vitals
In the Field (Real Users)
Send metrics to your analytics:
import { onLCP, onCLS, onINP } from 'web-vitals';
function sendToAnalytics(metric) {
fetch('/api/vitals', {
method: 'POST',
body: JSON.stringify(metric),
keepalive: true, // Survives page unload
});
}
onLCP(sendToAnalytics);
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
Google's Tools
- PageSpeed Insights: Lab + field data (CrUX) for any URL
- Chrome User Experience Report (CrUX): Real-world data from Chrome users
- Search Console: Core Web Vitals report for your site's URLs
- Lighthouse: Detailed lab audit with actionable recommendations
See Also
- Performance Overview - Setting budgets and goals
- Loading Strategies - Code splitting and lazy loading for better INP
- Asset Optimization - Image optimization for better LCP
- Profiling & Debugging - Using DevTools to diagnose issues
- web.dev Core Web Vitals - Google's official documentation