Core Web Vitals
Core Web Vitals are Google's standardized metrics for measuring real-world user experience on web pages. These metrics directly influence search rankings and serve as the foundation for understanding and improving page performance.
The Three Core Web Vitals
Largest Contentful Paint (LCP)
LCP measures how long it takes for the largest content element in the viewport to load. This is typically:
- A hero image
- A large text block
- A video thumbnail
| LCP Score | Rating |
|---|---|
| ≤ 2.5s | Good |
| 2.5s - 4s | Needs Improvement |
| > 4s | Poor |
Common causes of poor LCP:
- Slow server response time
- Render-blocking JavaScript and CSS
- Slow resource load times
- Client-side rendering delays
Interaction to Next Paint (INP)
INP replaced First Input Delay (FID) in March 2024. It measures responsiveness by tracking the latency of all clicks, taps, and keyboard interactions throughout the entire page visit, then reports the worst interaction (excluding outliers).
| INP Score | Rating |
|---|---|
| ≤ 200ms | Good |
| 200ms - 500ms | Needs Improvement |
| > 500ms | Poor |
Common causes of poor INP:
- Long-running JavaScript tasks
- Large DOM size
- Heavy event handlers
- Main thread blocking
Cumulative Layout Shift (CLS)
CLS measures visual stability by tracking how much the page layout shifts unexpectedly during loading. A layout shift occurs when a visible element changes position between rendered frames.
| CLS Score | Rating |
|---|---|
| ≤ 0.1 | Good |
| 0.1 - 0.25 | Needs Improvement |
| > 0.25 | Poor |
Common causes of poor CLS:
- Images without dimensions
- Ads, embeds, and iframes without reserved space
- Dynamically injected content
- Web fonts causing FOIT/FOUT
Measuring Core Web Vitals
Field Data (Real User Metrics)
Field data comes from actual users visiting your site:
- Navigate to Core Web Vitals report
- View mobile and desktop metrics separately
- See URLs grouped by similar experiences
- Track improvements over time
Chrome User Experience Report (CrUX):
// Access CrUX data via the CrUX API
const url = 'https://chromeuxreport.googleapis.com/v1/records:queryRecord';
const apiKey = 'YOUR_API_KEY';
fetch(`${url}?key=${apiKey}`, {
method: 'POST',
body: JSON.stringify({
url: 'https://example.com/',
metrics: ['largest_contentful_paint', 'interaction_to_next_paint', 'cumulative_layout_shift']
})
})
.then(response => response.json())
.then(data => console.log(data));
PageSpeed Insights:
- Visit PageSpeed Insights
- Enter your URL
- View both field data (real users) and lab data (simulated)
Lab Data (Simulated)
Lab data helps debug issues in controlled conditions:
Lighthouse (in Chrome DevTools):
- Open DevTools (F12)
- Navigate to Lighthouse tab
- Select "Performance" category
- Run audit
Web Vitals JavaScript Library:
<script type="module">
import {onCLS, onINP, onLCP} from 'https://unpkg.com/web-vitals@4?module';
function sendToAnalytics(metric) {
console.log(metric.name, metric.value, metric.rating);
// Send to Google Analytics
gtag('event', metric.name, {
value: Math.round(metric.name === 'CLS' ? metric.value * 1000 : metric.value),
event_category: 'Web Vitals',
event_label: metric.id,
non_interaction: true,
});
}
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
</script>
Optimizing LCP
1. Optimize Server Response Time
Target: Time to First Byte (TTFB) < 800ms
# Nginx: Enable compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
gzip_min_length 256;
# Enable caching
location ~* \.(jpg|jpeg|png|gif|ico|css|js)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
2. Optimize LCP Element Loading
Preload the LCP image:
<head>
<!-- Preload the hero image -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high">
</head>
Use responsive images:
<img
src="hero-800.webp"
srcset="hero-400.webp 400w, hero-800.webp 800w, hero-1200.webp 1200w"
sizes="(max-width: 600px) 400px, (max-width: 900px) 800px, 1200px"
alt="Hero image"
width="1200"
height="600"
fetchpriority="high"
/>
3. Eliminate Render-Blocking Resources
Defer non-critical JavaScript:
<!-- Critical JS inline -->
<script>
// Minimal inline critical JS
</script>
<!-- Non-critical JS deferred -->
<script src="main.js" defer></script>
Load CSS efficiently:
<!-- Critical CSS inline -->
<style>
/* Above-the-fold styles */
.hero { ... }
.nav { ... }
</style>
<!-- Non-critical CSS async -->
<link rel="preload" href="styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="styles.css"></noscript>
Optimizing INP
1. Break Up Long Tasks
JavaScript tasks blocking the main thread for >50ms hurt INP:
// BAD: Long synchronous task
function processLargeArray(items) {
items.forEach(item => heavyProcessing(item));
}
// GOOD: Yield to main thread periodically
async function processLargeArray(items) {
for (let i = 0; i < items.length; i++) {
heavyProcessing(items[i]);
// Yield every 50 items
if (i % 50 === 0) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
}
// BETTER: Use requestIdleCallback
function processLargeArray(items) {
let index = 0;
function processChunk(deadline) {
while (index < items.length && deadline.timeRemaining() > 0) {
heavyProcessing(items[index]);
index++;
}
if (index < items.length) {
requestIdleCallback(processChunk);
}
}
requestIdleCallback(processChunk);
}
2. Optimize Event Handlers
// BAD: Heavy work in click handler
button.addEventListener('click', () => {
// Heavy synchronous work
processData();
updateUI();
sendAnalytics();
});
// GOOD: Minimal work, defer the rest
button.addEventListener('click', () => {
// Immediate visual feedback
button.classList.add('clicked');
// Defer heavy work
requestAnimationFrame(() => {
processData();
updateUI();
});
// Analytics can be async
queueMicrotask(() => sendAnalytics());
});
3. Reduce DOM Size
Large DOMs slow down interactions:
// Check your DOM size
console.log('DOM elements:', document.querySelectorAll('*').length);
// Target: < 1,500 elements
// Warning: > 3,000 elements
Strategies:
- Use virtualization for long lists (react-window, vue-virtual-scroller)
- Lazy load off-screen content
- Remove hidden elements from DOM instead of hiding with CSS
Optimizing CLS
1. Always Set Image Dimensions
<!-- BAD: No dimensions -->
<img src="photo.jpg" alt="Photo">
<!-- GOOD: Explicit dimensions -->
<img src="photo.jpg" alt="Photo" width="800" height="600">
<!-- GOOD: CSS aspect ratio -->
<style>
.responsive-img {
aspect-ratio: 16 / 9;
width: 100%;
height: auto;
}
</style>
<img src="photo.jpg" alt="Photo" class="responsive-img">
2. Reserve Space for Dynamic Content
/* Reserve space for ads */
.ad-container {
min-height: 250px;
background: #f0f0f0;
}
/* Reserve space for embeds */
.video-embed {
aspect-ratio: 16 / 9;
width: 100%;
}
3. Avoid Inserting Content Above Existing Content
// BAD: Prepending content causes layout shift
container.prepend(newElement);
// GOOD: Append below viewport, or use transforms
container.append(newElement);
// BETTER: Use CSS transforms (don't cause layout shifts)
element.style.transform = 'translateY(-100px)';
4. Handle Web Fonts Properly
/* Use font-display to control loading behavior */
@font-face {
font-family: 'CustomFont';
src: url('custom-font.woff2') format('woff2');
font-display: swap; /* Show fallback immediately, swap when loaded */
}
/* Or use optional for non-critical fonts */
@font-face {
font-family: 'DecorativeFont';
src: url('decorative.woff2') format('woff2');
font-display: optional; /* Only use if already cached */
}
Match fallback font metrics:
/* Adjust fallback to match custom font */
@font-face {
font-family: 'CustomFont';
src: url('custom-font.woff2') format('woff2');
font-display: swap;
size-adjust: 105%;
ascent-override: 90%;
descent-override: 20%;
}
Monitoring Core Web Vitals in Production
Set Up Real User Monitoring
// Send to your analytics platform
import {onCLS, onINP, onLCP} from 'web-vitals';
function sendToAnalytics({name, value, rating, id, attribution}) {
// Send to GA4
gtag('event', name, {
value: Math.round(name === 'CLS' ? value * 1000 : value),
metric_id: id,
metric_rating: rating,
metric_value: value,
// Include attribution for debugging
debug_target: attribution?.element || attribution?.largestShiftTarget
});
}
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
Create Custom Alerts
Set up alerts for regressions:
In GA4:
- Create custom events for each Web Vital
- Build exploration reports by page/device
- Monitor 75th percentile thresholds
Third-party tools:
- SpeedCurve
- Calibre
- WebPageTest
Common Pitfalls
- Optimizing only for lab data - Lab tests don't reflect real-world conditions
- Ignoring mobile - Mobile users often have different experiences
- A/B testing without monitoring - Tests can impact Core Web Vitals
- Third-party scripts - Analytics, chat widgets, and ads often hurt performance
- Not testing after deployment - Performance can degrade over time
Checklist
- LCP ≤ 2.5s on 75th percentile of page loads
- INP ≤ 200ms on 75th percentile of interactions
- CLS ≤ 0.1 on 75th percentile of page loads
- All images have width/height attributes
- Critical CSS is inlined
- Non-critical JS is deferred
- Fonts use font-display: swap
- Real User Monitoring is configured
- Search Console shows "Good" status
Related Resources
- LCP Diagnostics - Deep dive on LCP issues
- INP Optimization - Interaction optimization
- CLS Fixes - Layout shift prevention
- Page Speed Performance - General performance