Web Vitals: The Complete Technical Deep Dive
Web Vitals: The Complete Technical Deep Dive
Understanding, Measuring, and Optimizing the Metrics That Define User Experience
Table of Contents
- Introduction
- The Evolution of Performance Metrics
- Core Web Vitals Overview
- Largest Contentful Paint (LCP)
- Interaction to Next Paint (INP)
- Cumulative Layout Shift (CLS)
- Other Important Metrics
- Browser APIs for Measurement
- Lab vs Field Data
- The web-vitals JavaScript Library
- Real User Monitoring (RUM)
- Chrome User Experience Report (CrUX)
- Optimization Strategies
- Framework-Specific Optimizations
- Debugging Performance Issues
- Performance Budgets
- SEO and Ranking Impact
- Advanced Topics
- Tools and Resources
- Future of Web Vitals
Introduction
Web Vitals is an initiative by Google to provide unified guidance for quality signals essential to delivering a great user experience on the web. These metrics quantify the real-world experience of users across three dimensions:
┌─────────────────────────────────────────────────────────────────┐
│ Web Vitals: The Three Pillars │
├─────────────────────────────────────────────────────────────────┤
│ │
│ USER EXPERIENCE │
│ │ │
│ ┌───────────────┼───────────────┐ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ LOADING │ │INTERACTI-│ │ VISUAL │ │
│ │ │ │ VITY │ │ STABILITY│ │
│ │ LCP │ │ INP │ │ CLS │ │
│ │ │ │ │ │ │ │
│ │ "Is it │ │ "Can I │ │ "Is it │ │
│ │ loading?"│ │ interact │ │ stable?" │ │
│ │ │ │ with it?"│ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
The Evolution of Performance Metrics
Historical Timeline
┌─────────────────────────────────────────────────────────────────┐
│ Performance Metrics Evolution │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ERA 1: Document-Centric (Pre-2010) │
│ ├── Page Load Time │
│ ├── DOMContentLoaded │
│ └── window.onload │
│ Problem: Didn't reflect actual user perception │
│ │
│ ERA 2: Milestone Metrics (2010-2017) │
│ ├── First Paint (FP) │
│ ├── First Contentful Paint (FCP) │
│ ├── Time to First Byte (TTFB) │
│ └── DOMInteractive │
│ Problem: Still didn't capture full experience │
│ │
│ ERA 3: User-Centric Metrics (2017-2020) │
│ ├── First Meaningful Paint (FMP) │
│ ├── Time to Interactive (TTI) │
│ ├── First Input Delay (FID) │
│ └── Speed Index │
│ Problem: Too many metrics, inconsistent measurement │
│ │
│ ERA 4: Core Web Vitals (2020-Present) │
│ ├── Largest Contentful Paint (LCP) │
│ ├── First Input Delay (FID) → Interaction to Next Paint (INP) │
│ └── Cumulative Layout Shift (CLS) │
│ Solution: Focused, standardized, user-centric │
│ │
│ Timeline: │
│ ───────────────────────────────────────────────────────────── │
│ 2020: Core Web Vitals announced │
│ 2021: Became ranking signal │
│ 2022: INP introduced as experimental │
│ 2024: INP replaced FID as Core Web Vital │
│ │
└─────────────────────────────────────────────────────────────────┘
Why These Specific Metrics?
// Each metric answers a critical user question:
// LCP: "When did the main content load?"
// - Users perceive pages as loaded when main content appears
// - More reliable than FMP (First Meaningful Paint)
// - Measurable and actionable
// INP: "How responsive is this page?"
// - Captures ALL interactions, not just first
// - Better represents real-world responsiveness
// - Replaced FID which only measured first input
// CLS: "Does the page stay still?"
// - Unexpected shifts frustrate users
// - Causes misclicks and disorientation
// - Unique metric not captured elsewhere
Core Web Vitals Overview
Current Thresholds (2024)
┌─────────────────────────────────────────────────────────────────┐
│ Core Web Vitals Thresholds │
├─────────────────────────────────────────────────────────────────┤
│ │
│ METRIC GOOD NEEDS IMPROVEMENT POOR │
│ ───────────────────────────────────────────────────────────── │
│ │
│ LCP ≤ 2.5s 2.5s - 4.0s > 4.0s │
│ ┌──────────────┬─────────────────────┬──────────────────┐ │
│ │ GREEN │ YELLOW │ RED │ │
│ └──────────────┴─────────────────────┴──────────────────┘ │
│ │
│ INP ≤ 200ms 200ms - 500ms > 500ms │
│ ┌──────────────┬─────────────────────┬──────────────────┐ │
│ │ GREEN │ YELLOW │ RED │ │
│ └──────────────┴─────────────────────┴──────────────────┘ │
│ │
│ CLS ≤ 0.1 0.1 - 0.25 > 0.25 │
│ ┌──────────────┬─────────────────────┬──────────────────┐ │
│ │ GREEN │ YELLOW │ RED │ │
│ └──────────────┴─────────────────────┴──────────────────┘ │
│ │
│ Percentile Target: 75th percentile of page loads │
│ (Not average! 75% of users should have good experience) │
│ │
└─────────────────────────────────────────────────────────────────┘
Passing Core Web Vitals Assessment
// A page passes if ALL THREE metrics are "Good" at 75th percentile
function assessCoreWebVitals(data) {
const p75 = (values) => {
const sorted = values.sort((a, b) => a - b);
const index = Math.ceil(sorted.length * 0.75) - 1;
return sorted[index];
};
const lcpP75 = p75(data.lcpValues);
const inpP75 = p75(data.inpValues);
const clsP75 = p75(data.clsValues);
const assessment = {
lcp: lcpP75 <= 2500 ? 'good' : lcpP75 <= 4000 ? 'needs-improvement' : 'poor',
inp: inpP75 <= 200 ? 'good' : inpP75 <= 500 ? 'needs-improvement' : 'poor',
cls: clsP75 <= 0.1 ? 'good' : clsP75 <= 0.25 ? 'needs-improvement' : 'poor',
};
const passes =
assessment.lcp === 'good' &&
assessment.inp === 'good' &&
assessment.cls === 'good';
return { assessment, passes, p75Values: { lcpP75, inpP75, clsP75 } };
}
Largest Contentful Paint (LCP)
What LCP Measures
LCP measures when the largest content element in the viewport becomes visible.
┌─────────────────────────────────────────────────────────────────┐
│ LCP Timeline │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Time ──────────────────────────────────────────────────────► │
│ │
│ 0ms Request start │
│ │ │
│ 100ms TTFB (server response) │
│ │ │
│ 300ms FCP (first content) │
│ │ ┌────────────────────────────┐ │
│ │ │ Logo │ [Skeleton] │ │
│ │ │ │ │ │
│ │ │ │ Loading... │ │
│ │ └────────────────────────────┘ │
│ │ │
│ 800ms Image starts loading │
│ │ │
│ 1500ms Hero image loaded ← LCP CANDIDATE │
│ │ ┌────────────────────────────┐ │
│ │ │ Logo │ │ │
│ │ │ │ ┌─────────────┐ │ │
│ │ │ │ │ HERO IMAGE │ │ ← Largest element │
│ │ │ │ │ (400x300) │ │ │
│ │ │ │ └─────────────┘ │ │
│ │ └────────────────────────────┘ │
│ │ │
│ 2000ms User interaction (scroll/click) → LCP FINALIZED │
│ │
│ Final LCP: 1500ms │
│ │
└─────────────────────────────────────────────────────────────────┘
LCP Candidate Elements
// Elements considered for LCP:
const lcpCandidates = [
'<img>', // Image elements
'<image> (inside <svg>)', // SVG images
'<video>', // Video poster image
'CSS background-image', // Elements with background images
'<p>, <h1>-<h6>, etc.', // Block-level text elements
];
// NOT considered:
const notLcpCandidates = [
'opacity: 0', // Invisible elements
'visibility: hidden', // Hidden elements
'Elements outside viewport', // Below the fold
'Placeholder/skeleton', // Low-entropy images
];
// The LCP element can CHANGE as the page loads
// Browser reports multiple LCP entries, last one before interaction wins
LCP Breakdown
┌─────────────────────────────────────────────────────────────────┐
│ LCP Sub-Parts │
├─────────────────────────────────────────────────────────────────┤
│ │
│ LCP = TTFB + Resource Load Delay + Resource Load Time + │
│ Element Render Delay │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ │ │
│ │ ┌──────┬───────────────┬────────────────┬───────────┐ │ │
│ │ │ TTFB │Resource Load │ Resource Load │ Element │ │ │
│ │ │ │ Delay │ Time │ Render │ │ │
│ │ │ │ │ │ Delay │ │ │
│ │ └──────┴───────────────┴────────────────┴───────────┘ │ │
│ │ │ │ │ │ │ │
│ │ ▼ ▼ ▼ ▼ │ │
│ │ Server Time until Download Time after │ │
│ │ response resource time for download │ │
│ │ time starts resource until render │ │
│ │ loading │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ Optimization targets: │
│ • TTFB: Server, CDN, caching │
│ • Resource Load Delay: Preload, priority hints │
│ • Resource Load Time: Compression, responsive images │
│ • Element Render Delay: Reduce render-blocking resources │
│ │
└─────────────────────────────────────────────────────────────────┘
Measuring LCP
// Using PerformanceObserver API
const observer = new PerformanceObserver((entryList) => {
const entries = entryList.getEntries();
for (const entry of entries) {
console.log('LCP candidate:', {
element: entry.element,
size: entry.size,
loadTime: entry.loadTime,
renderTime: entry.renderTime,
startTime: entry.startTime, // This is the LCP value
url: entry.url, // For images
});
}
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
// Entry properties:
interface LargestContentfulPaint extends PerformanceEntry {
element: Element | null; // The LCP element
size: number; // Size in pixels (width × height)
loadTime: number; // Resource load time (images)
renderTime: number; // When element was rendered
startTime: number; // LCP value (max of loadTime, renderTime)
url: string; // Resource URL (if applicable)
id: string; // Element ID
}
LCP Optimization Strategies
// ═══════════════════════════════════════════════════════════════
// 1. PRELOAD LCP RESOURCE
// ═══════════════════════════════════════════════════════════════
// In HTML <head>
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high">
// For responsive images
<link
rel="preload"
as="image"
href="/hero-mobile.webp"
media="(max-width: 600px)"
>
<link
rel="preload"
as="image"
href="/hero-desktop.webp"
media="(min-width: 601px)"
>
// ═══════════════════════════════════════════════════════════════
// 2. PRIORITY HINTS
// ═══════════════════════════════════════════════════════════════
// High priority for LCP image
<img src="/hero.webp" fetchpriority="high" alt="Hero">
// Low priority for below-fold images
<img src="/footer-logo.png" fetchpriority="low" loading="lazy" alt="Logo">
// ═══════════════════════════════════════════════════════════════
// 3. OPTIMIZE IMAGES
// ═══════════════════════════════════════════════════════════════
// Modern formats
<picture>
<source srcset="/hero.avif" type="image/avif">
<source srcset="/hero.webp" type="image/webp">
<img src="/hero.jpg" alt="Hero">
</picture>
// Responsive images
<img
srcset="
/hero-400.webp 400w,
/hero-800.webp 800w,
/hero-1200.webp 1200w
"
sizes="(max-width: 600px) 400px, (max-width: 1200px) 800px, 1200px"
src="/hero-800.webp"
alt="Hero"
>
// ═══════════════════════════════════════════════════════════════
// 4. ELIMINATE RENDER-BLOCKING RESOURCES
// ═══════════════════════════════════════════════════════════════
// Inline critical CSS
<style>
/* Critical above-fold styles */
.hero { ... }
</style>
// Defer non-critical CSS
<link rel="preload" href="/styles.css" as="style" onload="this.rel='stylesheet'">
// Async/defer JavaScript
<script src="/app.js" defer></script>
// ═══════════════════════════════════════════════════════════════
// 5. REDUCE TTFB
// ═══════════════════════════════════════════════════════════════
// Server-side
// - Use CDN
// - Enable caching
// - Optimize database queries
// - Use streaming SSR
// Response headers
Cache-Control: public, max-age=31536000, immutable
Interaction to Next Paint (INP)
What INP Measures
INP measures the latency of all user interactions throughout the page lifecycle, reporting the worst (or near-worst) interaction.
┌─────────────────────────────────────────────────────────────────┐
│ INP: Interaction Anatomy │
├─────────────────────────────────────────────────────────────────┤
│ │
│ User clicks button │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ INTERACTION │ │
│ │ │ │
│ │ ┌──────────┬─────────────────────┬──────────────────┐ │ │
│ │ │ INPUT │ PROCESSING │ PRESENTATION │ │ │
│ │ │ DELAY │ TIME │ DELAY │ │ │
│ │ │ │ │ │ │ │
│ │ │ ~10ms │ ~150ms │ ~40ms │ │ │
│ │ └──────────┴─────────────────────┴──────────────────┘ │ │
│ │ │ │ │ │ │
│ │ ▼ ▼ ▼ │ │
│ │ Queuing JavaScript Rendering │ │
│ │ behind execution and painting │ │
│ │ main thread (event handlers) (layout, paint) │ │
│ │ │ │
│ │ TOTAL INP = 10 + 150 + 40 = 200ms │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ User sees visual feedback │
│ │
└─────────────────────────────────────────────────────────────────┘
INP vs FID
┌─────────────────────────────────────────────────────────────────┐
│ INP vs FID Comparison │
├─────────────────────────────────────────────────────────────────┤
│ │
│ FID (Deprecated) INP (Current) │
│ ───────────────────────────────────────────────────────────── │
│ │
│ • First input only • ALL interactions │
│ • Only input delay • Full latency (input + │
│ • Single measurement processing + paint) │
│ • Misses slow interactions • Reports worst/near-worst │
│ after first • Better represents UX │
│ │
│ Timeline comparison: │
│ │
│ FID measures: │
│ User clicks ──► Event queued ──► Handler starts │
│ │ │ │ │
│ └────── FID ─────┘ │ │
│ (input delay only) (ignores this) │
│ │
│ INP measures: │
│ User clicks ──► Event queued ──► Handler runs ──► Paint done │
│ │ │ │
│ └────────────────── INP ───────────────────────────┘ │
│ (complete interaction latency) │
│ │
└─────────────────────────────────────────────────────────────────┘
Interaction Types
// Interactions that count toward INP:
const interactionTypes = {
// Pointer interactions
click: true, // Mouse click
tap: true, // Touch tap
// Keyboard interactions
keydown: true, // Key press
keyup: true, // Key release (paired with keydown)
// NOT counted:
scroll: false, // Handled differently
hover: false, // Not discrete interaction
mousemove: false, // Continuous, not discrete
};
// How INP is calculated:
function calculateINP(interactions) {
if (interactions.length === 0) return null;
// Sort by duration (descending)
const sorted = [...interactions].sort((a, b) => b.duration - a.duration);
// Pick the "worst" interaction (with some leniency for many interactions)
// Rule: highest percentile based on interaction count
const interactionCount = interactions.length;
if (interactionCount <= 50) {
return sorted[0].duration; // Worst interaction
} else {
// For pages with many interactions, use 98th percentile
// This is approximately: sort and take N/50th highest
const index = Math.min(Math.floor(interactionCount / 50), sorted.length - 1);
return sorted[index].duration;
}
}
Measuring INP
// Using PerformanceObserver
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Only count interactions with visual feedback
if (entry.interactionId) {
console.log('Interaction:', {
type: entry.name, // 'click', 'keydown', etc.
interactionId: entry.interactionId, // Groups related events
startTime: entry.startTime,
duration: entry.duration, // Total interaction time
processingStart: entry.processingStart,
processingEnd: entry.processingEnd,
// Calculated sub-parts:
inputDelay: entry.processingStart - entry.startTime,
processingTime: entry.processingEnd - entry.processingStart,
presentationDelay: entry.startTime + entry.duration - entry.processingEnd,
});
}
}
});
observer.observe({ type: 'event', buffered: true, durationThreshold: 16 });
// PerformanceEventTiming interface
interface PerformanceEventTiming extends PerformanceEntry {
processingStart: number; // When handler began
processingEnd: number; // When handler ended
interactionId: number; // Groups click/keydown+keyup
cancelable: boolean;
target: Node | null;
}
INP Optimization Strategies
// ═══════════════════════════════════════════════════════════════
// 1. REDUCE INPUT DELAY (Yield to Main Thread)
// ═══════════════════════════════════════════════════════════════
// ❌ Bad: Long task blocks input
function handleClick() {
processData(); // 200ms
updateUI(); // 50ms
sendAnalytics(); // 30ms
}
// Total blocking: 280ms
// ✅ Good: Yield between tasks
async function handleClick() {
processData(); // 200ms
await scheduler.yield(); // Yield to browser
updateUI(); // 50ms
await scheduler.yield();
sendAnalytics(); // 30ms
}
// Fallback for scheduler.yield()
function yieldToMain() {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
// ═══════════════════════════════════════════════════════════════
// 2. REDUCE PROCESSING TIME (Break Up Long Tasks)
// ═══════════════════════════════════════════════════════════════
// ❌ Bad: Process all items synchronously
function processItems(items) {
items.forEach(item => expensiveOperation(item));
}
// ✅ Good: Process in chunks
async function processItems(items) {
const CHUNK_SIZE = 10;
for (let i = 0; i < items.length; i += CHUNK_SIZE) {
const chunk = items.slice(i, i + CHUNK_SIZE);
chunk.forEach(item => expensiveOperation(item));
// Yield between chunks
await yieldToMain();
}
}
// ═══════════════════════════════════════════════════════════════
// 3. AVOID LAYOUT THRASHING
// ═══════════════════════════════════════════════════════════════
// ❌ Bad: Interleaved reads and writes
function updateElements(elements) {
elements.forEach(el => {
const height = el.offsetHeight; // READ (forces layout)
el.style.height = height + 10 + 'px'; // WRITE
});
// Each iteration forces a layout recalculation!
}
// ✅ Good: Batch reads, then batch writes
function updateElements(elements) {
// Batch reads
const heights = elements.map(el => el.offsetHeight);
// Batch writes
elements.forEach((el, i) => {
el.style.height = heights[i] + 10 + 'px';
});
// Single layout recalculation
}
// ═══════════════════════════════════════════════════════════════
// 4. USE requestAnimationFrame FOR VISUAL UPDATES
// ═══════════════════════════════════════════════════════════════
function handleScroll() {
// Debounce visual updates to animation frame
if (!ticking) {
requestAnimationFrame(() => {
updateVisuals();
ticking = false;
});
ticking = true;
}
}
// ═══════════════════════════════════════════════════════════════
// 5. DEFER NON-CRITICAL WORK
// ═══════════════════════════════════════════════════════════════
function handleClick() {
// Critical: immediate feedback
button.classList.add('active');
// Non-critical: defer
requestIdleCallback(() => {
sendAnalytics();
preloadNextPage();
});
}
// ═══════════════════════════════════════════════════════════════
// 6. WEB WORKERS FOR HEAVY COMPUTATION
// ═══════════════════════════════════════════════════════════════
// main.js
const worker = new Worker('worker.js');
function handleClick(data) {
// Show immediate feedback
showSpinner();
// Offload heavy work
worker.postMessage({ type: 'process', data });
}
worker.onmessage = (e) => {
hideSpinner();
displayResults(e.data);
};
// worker.js
self.onmessage = (e) => {
const result = heavyComputation(e.data);
self.postMessage(result);
};
Cumulative Layout Shift (CLS)
What CLS Measures
CLS quantifies how much visible content unexpectedly shifts during the page's lifetime.
┌─────────────────────────────────────────────────────────────────┐
│ Layout Shift Visualization │
├─────────────────────────────────────────────────────────────────┤
│ │
│ BEFORE AD LOADS AFTER AD LOADS │
│ ┌────────────────────┐ ┌────────────────────┐ │
│ │ HEADER │ │ HEADER │ │
│ ├────────────────────┤ ├────────────────────┤ │
│ │ │ │ ┌────────────────┐ │ │
│ │ Article Title │ │ │ SURPRISE │ │ │
│ │ │ │ │ AD!!! │ │ ← NEW │
│ │ Lorem ipsum dolor │ │ └────────────────┘ │ │
│ │ sit amet... │ ├────────────────────┤ │
│ │ │ │ Article Title │ ← SHIFTED
│ │ [Read More Button] │ ← Target │ │ │
│ │ │ │ Lorem ipsum dolor │ │
│ └────────────────────┘ │ sit amet... │ ← SHIFTED
│ │ │ │
│ User clicks here ─────────────────► [Read More Button] │ ← SHIFTED
│ but hits the ad instead! │ │ │
│ └────────────────────┘ │
│ │
│ This shift contributes to CLS score │
│ │
└─────────────────────────────────────────────────────────────────┘
CLS Calculation Formula
// Layout Shift Score = Impact Fraction × Distance Fraction
// ═══════════════════════════════════════════════════════════════
// IMPACT FRACTION
// ═══════════════════════════════════════════════════════════════
// The total area of viewport affected by the shift
/*
┌─────────────────────┐
│ │
│ ┌───────────┐ │ Element before shift
│ │ BEFORE │ │
│ └───────────┘ │
│ ↓ │
│ ┌───────────┐ │ Element after shift
│ │ AFTER │ │
│ └───────────┘ │
│ │
│ ▓▓▓▓▓▓▓▓▓▓▓▓ │ ← Impact region (union of before + after)
│ │
└─────────────────────┘
Impact Fraction = (Impact Region Area) / (Viewport Area)
*/
// ═══════════════════════════════════════════════════════════════
// DISTANCE FRACTION
// ═══════════════════════════════════════════════════════════════
// How far the element moved relative to viewport
/*
Distance Fraction = (Max Distance Moved) / (Viewport Height or Width)
Example: Element moved 100px in a 800px viewport
Distance Fraction = 100 / 800 = 0.125
*/
// ═══════════════════════════════════════════════════════════════
// EXAMPLE CALCULATION
// ═══════════════════════════════════════════════════════════════
function calculateLayoutShiftScore(shift) {
const viewport = { width: 1920, height: 1080 };
// Element positions
const before = { top: 100, left: 0, width: 400, height: 200 };
const after = { top: 300, left: 0, width: 400, height: 200 };
// Impact region (union of both positions)
const impactRegion = {
top: Math.min(before.top, after.top),
bottom: Math.max(before.top + before.height, after.top + after.height),
left: Math.min(before.left, after.left),
right: Math.max(before.left + before.width, after.left + after.width),
};
const impactArea =
(impactRegion.bottom - impactRegion.top) *
(impactRegion.right - impactRegion.left);
const viewportArea = viewport.width * viewport.height;
const impactFraction = impactArea / viewportArea;
// Distance moved
const distanceY = Math.abs(after.top - before.top);
const distanceX = Math.abs(after.left - before.left);
const maxDistance = Math.max(distanceY, distanceX);
const distanceFraction = maxDistance / Math.max(viewport.height, viewport.width);
// Layout shift score
const score = impactFraction * distanceFraction;
return score; // e.g., 0.15
}
Session Windows
┌─────────────────────────────────────────────────────────────────┐
│ CLS Session Windows │
├─────────────────────────────────────────────────────────────────┤
│ │
│ CLS uses "session windows" to group related shifts: │
│ │
│ • Maximum 5 seconds per window │
│ • Maximum 1 second gap between shifts │
│ • Report the MAXIMUM window value (not sum of all) │
│ │
│ Timeline: │
│ │
│ ──┬─────┬─────────┬──────────────────┬─────┬─────────────► │
│ │ │ │ │ │ │
│ Shift Shift (2s gap) Shift Shift │
│ 0.02 0.03 0.10 0.05 │
│ └──┬──┘ └──┬──┘ │
│ │ │ │
│ Window 1: 0.05 Window 2: 0.15 │
│ │
│ Final CLS = max(0.05, 0.15) = 0.15 │
│ │
│ Why session windows? │
│ • Fairer for long-lived pages (SPAs) │
│ • One bad shift doesn't ruin entire session │
│ • Groups related shifts together │
│ │
└─────────────────────────────────────────────────────────────────┘
Measuring CLS
// Using PerformanceObserver
let clsValue = 0;
let clsEntries = [];
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Only count unexpected shifts (no recent user input)
if (!entry.hadRecentInput) {
clsValue += entry.value;
clsEntries.push(entry);
console.log('Layout shift:', {
value: entry.value,
sources: entry.sources?.map(s => ({
node: s.node,
previousRect: s.previousRect,
currentRect: s.currentRect,
})),
hadRecentInput: entry.hadRecentInput,
lastInputTime: entry.lastInputTime,
});
}
}
});
observer.observe({ type: 'layout-shift', buffered: true });
// LayoutShift interface
interface LayoutShift extends PerformanceEntry {
value: number; // Shift score
hadRecentInput: boolean; // User-initiated?
lastInputTime: number; // Time of last input
sources: LayoutShiftAttribution[];// Elements that shifted
}
interface LayoutShiftAttribution {
node: Node | null;
previousRect: DOMRectReadOnly;
currentRect: DOMRectReadOnly;
}
CLS Optimization Strategies
<!-- ═══════════════════════════════════════════════════════════════
1. ALWAYS INCLUDE SIZE ATTRIBUTES ON IMAGES/VIDEOS
═══════════════════════════════════════════════════════════════ -->
<!-- ❌ Bad: No dimensions -->
<img src="/photo.jpg" alt="Photo">
<!-- ✅ Good: Explicit dimensions -->
<img src="/photo.jpg" width="800" height="600" alt="Photo">
<!-- ✅ Better: Aspect ratio with CSS -->
<style>
.responsive-img {
aspect-ratio: 16 / 9;
width: 100%;
height: auto;
}
</style>
<img src="/photo.jpg" class="responsive-img" alt="Photo">
<!-- ═══════════════════════════════════════════════════════════════
2. RESERVE SPACE FOR ADS AND EMBEDS
═══════════════════════════════════════════════════════════════ -->
<style>
.ad-container {
min-height: 250px; /* Reserve space */
background: #f0f0f0;
}
</style>
<div class="ad-container">
<!-- Ad loads here -->
</div>
<!-- ═══════════════════════════════════════════════════════════════
3. AVOID INSERTING CONTENT ABOVE EXISTING CONTENT
═══════════════════════════════════════════════════════════════ -->
<!-- ❌ Bad: Banner inserted at top pushes content down -->
<body>
<div id="dynamic-banner"></div> <!-- Injected later -->
<main>Content...</main>
</body>
<!-- ✅ Good: Fixed position overlay or reserved space -->
<body>
<div class="banner-slot" style="min-height: 60px;">
<!-- Banner loads here without shifting -->
</div>
<main>Content...</main>
</body>
<!-- ═══════════════════════════════════════════════════════════════
4. USE CSS TRANSFORMS FOR ANIMATIONS (NOT LAYOUT PROPERTIES)
═══════════════════════════════════════════════════════════════ -->
<style>
/* ❌ Bad: Animating layout properties */
.bad-animation {
animation: bad 0.3s;
}
@keyframes bad {
from { top: 0; left: 0; }
to { top: 100px; left: 100px; }
}
/* ✅ Good: Using transform */
.good-animation {
animation: good 0.3s;
}
@keyframes good {
from { transform: translate(0, 0); }
to { transform: translate(100px, 100px); }
}
</style>
<!-- ═══════════════════════════════════════════════════════════════
5. PRELOAD WEB FONTS
═══════════════════════════════════════════════════════════════ -->
<head>
<!-- Preload fonts to avoid FOUT/FOIT -->
<link
rel="preload"
href="/fonts/roboto.woff2"
as="font"
type="font/woff2"
crossorigin
>
<style>
@font-face {
font-family: 'Roboto';
src: url('/fonts/roboto.woff2') format('woff2');
font-display: swap; /* Show fallback immediately */
}
body {
font-family: 'Roboto', system-ui, sans-serif;
}
</style>
</head>
// ═══════════════════════════════════════════════════════════════
// 6. USE CONTENT-VISIBILITY FOR OFF-SCREEN CONTENT
// ═══════════════════════════════════════════════════════════════
// CSS
.below-fold-section {
content-visibility: auto;
contain-intrinsic-size: 0 500px; /* Estimated height */
}
// ═══════════════════════════════════════════════════════════════
// 7. HANDLE DYNAMIC CONTENT CAREFULLY
// ═══════════════════════════════════════════════════════════════
// ❌ Bad: Inserting at top
function addNotification(message) {
const div = document.createElement('div');
div.textContent = message;
document.body.prepend(div); // Shifts everything down!
}
// ✅ Good: Fixed position or reserved space
function addNotification(message) {
const container = document.getElementById('notification-area');
const div = document.createElement('div');
div.textContent = message;
container.appendChild(div); // Dedicated area
}
// ═══════════════════════════════════════════════════════════════
// 8. SKELETON SCREENS WITH MATCHING DIMENSIONS
// ═══════════════════════════════════════════════════════════════
function ProductCard({ product, isLoading }) {
return (
<div className="product-card" style={{ height: '300px' }}>
{isLoading ? (
<Skeleton height={300} /> // Same height as loaded content
) : (
<>
<img src={product.image} />
<h3>{product.name}</h3>
<p>{product.price}</p>
</>
)}
</div>
);
}
Other Important Metrics
Time to First Byte (TTFB)
// TTFB measures server responsiveness
// Target: < 800ms
const navigationEntry = performance.getEntriesByType('navigation')[0];
const ttfb = navigationEntry.responseStart - navigationEntry.requestStart;
// TTFB breakdown
const timing = {
dns: navigationEntry.domainLookupEnd - navigationEntry.domainLookupStart,
tcp: navigationEntry.connectEnd - navigationEntry.connectStart,
ssl: navigationEntry.secureConnectionStart > 0
? navigationEntry.connectEnd - navigationEntry.secureConnectionStart
: 0,
request: navigationEntry.responseStart - navigationEntry.requestStart,
ttfb: navigationEntry.responseStart - navigationEntry.fetchStart,
};
First Contentful Paint (FCP)
// FCP: When first content appears
// Target: < 1.8s
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('FCP:', entry.startTime);
}
});
observer.observe({ type: 'paint', buffered: true });
Time to Interactive (TTI)
// TTI: When page becomes reliably interactive
// No standard browser API, typically measured via Lighthouse
// Conditions for TTI:
// 1. FCP has occurred
// 2. Event handlers are registered for visible elements
// 3. Page responds to interactions within 50ms
// 4. No long tasks (> 50ms) blocking main thread
Total Blocking Time (TBT)
// TBT: Sum of blocking time from Long Tasks between FCP and TTI
// Target: < 200ms
const observer = new PerformanceObserver((list) => {
let totalBlockingTime = 0;
for (const entry of list.getEntries()) {
// Long task is > 50ms
// Blocking time is the portion over 50ms
const blockingTime = entry.duration - 50;
if (blockingTime > 0) {
totalBlockingTime += blockingTime;
}
}
console.log('Current TBT:', totalBlockingTime);
});
observer.observe({ type: 'longtask', buffered: true });
Speed Index
// Speed Index: Visual progress of page load
// Measured by analyzing video frames of page loading
// Target: < 3.4s
// Not directly measurable in JavaScript
// Use Lighthouse or WebPageTest
Browser APIs for Measurement
PerformanceObserver Comprehensive Example
class WebVitalsCollector {
constructor() {
this.metrics = {
lcp: null,
inp: null,
cls: 0,
fcp: null,
ttfb: null,
};
this.interactions = [];
this.layoutShifts = [];
this.init();
}
init() {
this.observeLCP();
this.observeINP();
this.observeCLS();
this.observeFCP();
this.measureTTFB();
}
observeLCP() {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
this.metrics.lcp = lastEntry.startTime;
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
}
observeINP() {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.interactionId) {
this.interactions.push({
id: entry.interactionId,
duration: entry.duration,
type: entry.name,
});
}
}
// Calculate INP
this.metrics.inp = this.calculateINP();
});
observer.observe({ type: 'event', buffered: true, durationThreshold: 16 });
}
calculateINP() {
if (this.interactions.length === 0) return null;
// Group by interaction ID and take max duration per interaction
const interactionMap = new Map();
for (const interaction of this.interactions) {
const existing = interactionMap.get(interaction.id);
if (!existing || interaction.duration > existing.duration) {
interactionMap.set(interaction.id, interaction);
}
}
const durations = Array.from(interactionMap.values())
.map(i => i.duration)
.sort((a, b) => b - a);
// 98th percentile approximation
const index = Math.min(
Math.floor(durations.length / 50),
durations.length - 1
);
return durations[index];
}
observeCLS() {
let sessionValue = 0;
let sessionEntries = [];
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) {
const firstEntry = sessionEntries[0];
const lastEntry = sessionEntries[sessionEntries.length - 1];
// Start new session if gap > 1s or session > 5s
if (
sessionEntries.length &&
(entry.startTime - lastEntry.startTime > 1000 ||
entry.startTime - firstEntry.startTime > 5000)
) {
// Save max session
if (sessionValue > this.metrics.cls) {
this.metrics.cls = sessionValue;
}
sessionValue = entry.value;
sessionEntries = [entry];
} else {
sessionValue += entry.value;
sessionEntries.push(entry);
}
this.layoutShifts.push(entry);
}
}
// Update with current session
if (sessionValue > this.metrics.cls) {
this.metrics.cls = sessionValue;
}
});
observer.observe({ type: 'layout-shift', buffered: true });
}
observeFCP() {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-contentful-paint') {
this.metrics.fcp = entry.startTime;
}
}
});
observer.observe({ type: 'paint', buffered: true });
}
measureTTFB() {
const navigation = performance.getEntriesByType('navigation')[0];
if (navigation) {
this.metrics.ttfb = navigation.responseStart;
}
}
getMetrics() {
return { ...this.metrics };
}
report() {
// Send to analytics
fetch('/analytics/vitals', {
method: 'POST',
body: JSON.stringify(this.getMetrics()),
headers: { 'Content-Type': 'application/json' },
// Use keepalive for page unload
keepalive: true,
});
}
}
// Usage
const collector = new WebVitalsCollector();
// Report on page hide (most reliable)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
collector.report();
}
});
Lab vs Field Data
Understanding the Difference
┌─────────────────────────────────────────────────────────────────┐
│ Lab Data vs Field Data │
├─────────────────────────────────────────────────────────────────┤
│ │
│ LAB DATA (Synthetic) FIELD DATA (RUM) │
│ ───────────────────────────────────────────────────────────── │
│ │
│ Source: Source: │
│ • Lighthouse • Chrome User Experience │
│ • WebPageTest Report (CrUX) │
│ • Chrome DevTools • Your own RUM solution │
│ • PageSpeed Insights (lab) • PageSpeed Insights (field) │
│ │
│ Characteristics: Characteristics: │
│ • Controlled environment • Real user conditions │
│ • Reproducible • Diverse devices/networks │
│ • Simulated throttling • Actual user behavior │
│ • Single device/network • 28-day rolling data │
│ • No user interaction • Includes INP, CLS over time │
│ │
│ Metrics available: Metrics available: │
│ • LCP ✓ • LCP ✓ │
│ • CLS ✓ (load only) • CLS ✓ (full session) │
│ • TBT ✓ (proxy for INP) • INP ✓ │
│ • FCP ✓ • FCP ✓ │
│ • TTI ✓ • TTFB ✓ │
│ • Speed Index ✓ • (No TTI, Speed Index) │
│ │
│ Use cases: Use cases: │
│ • Development debugging • SEO ranking signal │
│ • CI/CD testing • Real user experience │
│ • Identifying regressions • Geographic analysis │
│ • A/B testing in isolation • Device/browser breakdown │
│ │
└─────────────────────────────────────────────────────────────────┘
Why Metrics Differ
// Common reasons for lab vs field discrepancies:
const discrepancyReasons = {
// LCP differences
lcp: [
'Lab uses specific viewport; users have varied screen sizes',
'Lab has no personalization; users see personalized content',
'Lab tests cold cache; returning users have warm cache',
'CDN performance varies by user location',
'Third-party resources load differently',
],
// INP differences (TBT in lab)
inp: [
'Lab measures TBT during load; INP measures all interactions',
'Users interact differently than synthetic tests',
'Dynamic content loaded after lab test completes',
'Extensions and browser differences',
],
// CLS differences
cls: [
'Lab only measures during initial load',
'Users scroll and interact, causing more shifts',
'Lazy-loaded content shifts after lab test',
'Ads load at different times for real users',
'SPA navigations cause additional shifts',
],
};
The web-vitals JavaScript Library
Installation and Basic Usage
// Installation
npm install web-vitals
// ES Module import
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';
// Basic usage
onCLS(console.log);
onINP(console.log);
onLCP(console.log);
onFCP(console.log);
onTTFB(console.log);
// Metric object structure
interface Metric {
name: 'CLS' | 'INP' | 'LCP' | 'FCP' | 'TTFB';
value: number;
rating: 'good' | 'needs-improvement' | 'poor';
delta: number; // Change since last report
entries: PerformanceEntry[]; // Raw entries
id: string; // Unique ID for this metric
navigationType: 'navigate' | 'reload' | 'back-forward' | 'prerender';
}
Advanced Configuration
import { onCLS, onINP, onLCP } from 'web-vitals';
// Report all changes (not just final value)
onCLS((metric) => {
console.log('CLS updated:', metric.value, 'delta:', metric.delta);
}, { reportAllChanges: true });
// Attribution build (more debugging info)
import { onLCP } from 'web-vitals/attribution';
onLCP((metric) => {
console.log('LCP:', metric.value);
console.log('LCP element:', metric.attribution.element);
console.log('LCP resource URL:', metric.attribution.url);
console.log('TTFB:', metric.attribution.timeToFirstByte);
console.log('Resource load delay:', metric.attribution.resourceLoadDelay);
console.log('Resource load time:', metric.attribution.resourceLoadTime);
console.log('Element render delay:', metric.attribution.elementRenderDelay);
});
// INP attribution
import { onINP } from 'web-vitals/attribution';
onINP((metric) => {
console.log('INP:', metric.value);
console.log('Interaction target:', metric.attribution.interactionTarget);
console.log('Interaction type:', metric.attribution.interactionType);
console.log('Input delay:', metric.attribution.inputDelay);
console.log('Processing time:', metric.attribution.processingDuration);
console.log('Presentation delay:', metric.attribution.presentationDelay);
console.log('Long tasks:', metric.attribution.longAnimationFrameEntries);
});
// CLS attribution
import { onCLS } from 'web-vitals/attribution';
onCLS((metric) => {
console.log('CLS:', metric.value);
console.log('Largest shift target:', metric.attribution.largestShiftTarget);
console.log('Largest shift time:', metric.attribution.largestShiftTime);
console.log('Largest shift value:', metric.attribution.largestShiftValue);
console.log('Largest shift sources:', metric.attribution.largestShiftSources);
});
Sending to Analytics
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';
function sendToAnalytics(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
id: metric.id,
page: window.location.pathname,
navigationType: metric.navigationType,
});
// Use sendBeacon for reliability
if (navigator.sendBeacon) {
navigator.sendBeacon('/analytics', body);
} else {
fetch('/analytics', {
body,
method: 'POST',
keepalive: true,
});
}
}
// Report final values
onCLS(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);
// Google Analytics 4 integration
function sendToGA4(metric) {
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,
});
}
Real User Monitoring (RUM)
Building a RUM System
class RUMCollector {
constructor(options = {}) {
this.endpoint = options.endpoint || '/rum';
this.sampleRate = options.sampleRate || 1.0;
this.buffer = [];
this.sessionId = this.generateSessionId();
// Don't collect if not sampled
if (Math.random() > this.sampleRate) {
this.disabled = true;
return;
}
this.init();
}
generateSessionId() {
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
init() {
this.collectNavigationTiming();
this.collectResourceTiming();
this.collectWebVitals();
this.collectErrors();
this.setupBeacon();
}
collectNavigationTiming() {
// Wait for load event
window.addEventListener('load', () => {
setTimeout(() => {
const nav = performance.getEntriesByType('navigation')[0];
if (!nav) return;
this.add({
type: 'navigation',
data: {
dns: nav.domainLookupEnd - nav.domainLookupStart,
tcp: nav.connectEnd - nav.connectStart,
ssl: nav.secureConnectionStart > 0
? nav.connectEnd - nav.secureConnectionStart
: 0,
ttfb: nav.responseStart - nav.requestStart,
download: nav.responseEnd - nav.responseStart,
domInteractive: nav.domInteractive,
domContentLoaded: nav.domContentLoadedEventEnd,
domComplete: nav.domComplete,
loadEvent: nav.loadEventEnd,
transferSize: nav.transferSize,
encodedBodySize: nav.encodedBodySize,
decodedBodySize: nav.decodedBodySize,
},
});
}, 0);
});
}
collectResourceTiming() {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Only track significant resources
if (entry.initiatorType === 'script' ||
entry.initiatorType === 'css' ||
entry.initiatorType === 'img') {
this.add({
type: 'resource',
data: {
name: entry.name,
initiatorType: entry.initiatorType,
duration: entry.duration,
transferSize: entry.transferSize,
startTime: entry.startTime,
},
});
}
}
});
observer.observe({ type: 'resource', buffered: true });
}
collectWebVitals() {
import('web-vitals').then(({ onCLS, onINP, onLCP, onFCP, onTTFB }) => {
const vitalsHandler = (metric) => {
this.add({
type: 'web-vital',
data: {
name: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
navigationType: metric.navigationType,
},
});
};
onCLS(vitalsHandler);
onINP(vitalsHandler);
onLCP(vitalsHandler);
onFCP(vitalsHandler);
onTTFB(vitalsHandler);
});
}
collectErrors() {
window.addEventListener('error', (event) => {
this.add({
type: 'error',
data: {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
},
});
});
window.addEventListener('unhandledrejection', (event) => {
this.add({
type: 'unhandled-rejection',
data: {
reason: String(event.reason),
},
});
});
}
add(entry) {
if (this.disabled) return;
this.buffer.push({
...entry,
timestamp: Date.now(),
sessionId: this.sessionId,
page: window.location.pathname,
userAgent: navigator.userAgent,
connection: navigator.connection ? {
effectiveType: navigator.connection.effectiveType,
rtt: navigator.connection.rtt,
downlink: navigator.connection.downlink,
} : null,
});
}
setupBeacon() {
// Send on page hide (most reliable)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
this.flush();
}
});
// Fallback for page unload
window.addEventListener('pagehide', () => {
this.flush();
});
// Periodic flush for long sessions
setInterval(() => {
if (this.buffer.length > 0) {
this.flush();
}
}, 30000);
}
flush() {
if (this.buffer.length === 0) return;
const data = JSON.stringify(this.buffer);
this.buffer = [];
if (navigator.sendBeacon) {
navigator.sendBeacon(this.endpoint, data);
} else {
fetch(this.endpoint, {
method: 'POST',
body: data,
keepalive: true,
headers: { 'Content-Type': 'application/json' },
});
}
}
}
// Initialize
new RUMCollector({
endpoint: '/api/rum',
sampleRate: 0.1, // 10% of users
});
Chrome User Experience Report (CrUX)
Accessing CrUX Data
// ═══════════════════════════════════════════════════════════════
// 1. CrUX API (Real-time, URL or origin level)
// ═══════════════════════════════════════════════════════════════
async function getCrUXData(url, apiKey) {
const response = await fetch(
`https://chromeuxreport.googleapis.com/v1/records:queryRecord?key=${apiKey}`,
{
method: 'POST',
body: JSON.stringify({
url: url, // or use origin: 'https://example.com'
formFactor: 'PHONE', // PHONE, DESKTOP, TABLET, or omit for all
metrics: [
'largest_contentful_paint',
'interaction_to_next_paint',
'cumulative_layout_shift',
'first_contentful_paint',
'experimental_time_to_first_byte',
],
}),
}
);
return response.json();
}
// Response structure
{
"record": {
"key": { "url": "https://example.com/" },
"metrics": {
"largest_contentful_paint": {
"histogram": [
{ "start": 0, "end": 2500, "density": 0.75 }, // Good
{ "start": 2500, "end": 4000, "density": 0.15 }, // Needs improvement
{ "start": 4000, "density": 0.10 } // Poor
],
"percentiles": {
"p75": 2100
}
},
// ... other metrics
},
"collectionPeriod": {
"firstDate": { "year": 2024, "month": 1, "day": 1 },
"lastDate": { "year": 2024, "month": 1, "day": 28 }
}
}
}
// ═══════════════════════════════════════════════════════════════
// 2. CrUX BigQuery (Historical, detailed analysis)
// ═══════════════════════════════════════════════════════════════
// Query in Google BigQuery
const query = `
SELECT
origin,
form_factor.name AS device,
-- LCP percentiles
\`chrome-ux-report\`.experimental.PERCENTILE(
largest_contentful_paint.histogram.bin, 0.75
) AS lcp_p75,
-- INP percentiles
\`chrome-ux-report\`.experimental.PERCENTILE(
interaction_to_next_paint.histogram.bin, 0.75
) AS inp_p75,
-- CLS percentiles
\`chrome-ux-report\`.experimental.PERCENTILE(
layout_instability.cumulative_layout_shift.histogram.bin, 0.75
) AS cls_p75
FROM
\`chrome-ux-report.all.202401\` -- Monthly table
WHERE
origin = 'https://example.com'
GROUP BY
origin, device
`;
// ═══════════════════════════════════════════════════════════════
// 3. PageSpeed Insights API (Combines Lab + Field)
// ═══════════════════════════════════════════════════════════════
async function getPageSpeedInsights(url, apiKey) {
const response = await fetch(
`https://www.googleapis.com/pagespeedonline/v5/runPagespeed?` +
`url=${encodeURIComponent(url)}&` +
`key=${apiKey}&` +
`strategy=mobile&` +
`category=performance`
);
const data = await response.json();
return {
// Field data (CrUX)
fieldMetrics: data.loadingExperience?.metrics,
// Lab data (Lighthouse)
labMetrics: data.lighthouseResult?.audits,
// Overall scores
scores: {
performance: data.lighthouseResult?.categories?.performance?.score * 100,
fcp: data.lighthouseResult?.audits?.['first-contentful-paint']?.numericValue,
lcp: data.lighthouseResult?.audits?.['largest-contentful-paint']?.numericValue,
tbt: data.lighthouseResult?.audits?.['total-blocking-time']?.numericValue,
cls: data.lighthouseResult?.audits?.['cumulative-layout-shift']?.numericValue,
},
};
}
Optimization Strategies
Comprehensive Optimization Checklist
┌─────────────────────────────────────────────────────────────────┐
│ Web Vitals Optimization Checklist │
├─────────────────────────────────────────────────────────────────┤
│ │
│ LCP OPTIMIZATION │
│ ───────────────── │
│ □ Preload LCP image with fetchpriority="high" │
│ □ Use responsive images (srcset, sizes) │
│ □ Serve modern formats (WebP, AVIF) │
│ □ Use CDN for static assets │
│ □ Inline critical CSS │
│ □ Defer non-critical CSS/JS │
│ □ Reduce server response time (TTFB) │
│ □ Avoid lazy loading LCP image │
│ □ Remove render-blocking resources │
│ │
│ INP OPTIMIZATION │
│ ───────────────── │
│ □ Break up long tasks (> 50ms) │
│ □ Yield to main thread during heavy work │
│ □ Use requestIdleCallback for non-urgent work │
│ □ Debounce/throttle event handlers │
│ □ Avoid layout thrashing │
│ □ Use CSS transforms instead of layout properties │
│ □ Virtualize long lists │
│ □ Use Web Workers for CPU-intensive tasks │
│ □ Lazy load non-critical JavaScript │
│ □ Minimize third-party JavaScript impact │
│ │
│ CLS OPTIMIZATION │
│ ───────────────── │
│ □ Set explicit dimensions on images/videos │
│ □ Reserve space for ads/embeds │
│ □ Avoid inserting content above existing content │
│ □ Preload fonts with font-display: swap │
│ □ Use aspect-ratio CSS property │
│ □ Avoid animations that trigger layout │
│ □ Use content-visibility for below-fold content │
│ □ Handle dynamic content with placeholders │
│ │
│ GENERAL │
│ ───────────────── │
│ □ Enable HTTP/2 or HTTP/3 │
│ □ Compress text resources (Brotli > gzip) │
│ □ Set proper cache headers │
│ □ Use service worker for caching │
│ □ Monitor with RUM and set alerts │
│ │
└─────────────────────────────────────────────────────────────────┘
Resource Prioritization
<!DOCTYPE html>
<html>
<head>
<!-- Preconnect to critical origins -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<!-- DNS prefetch for less critical origins -->
<link rel="dns-prefetch" href="https://analytics.example.com">
<!-- Preload critical resources -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/hero.webp" as="image" fetchpriority="high">
<link rel="preload" href="/critical.css" as="style">
<!-- Inline critical CSS -->
<style>
/* Above-the-fold styles */
.hero { /* ... */ }
.header { /* ... */ }
</style>
<!-- Defer non-critical CSS -->
<link rel="preload" href="/styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="/styles.css"></noscript>
<!-- Module scripts are deferred by default -->
<script type="module" src="/app.js"></script>
</head>
<body>
<!-- LCP candidate with high priority -->
<img src="/hero.webp" fetchpriority="high" alt="Hero">
<!-- Below-fold images with lazy loading -->
<img src="/product.webp" loading="lazy" fetchpriority="low" alt="Product">
<!-- Third-party scripts with low priority -->
<script src="https://analytics.example.com/tracker.js" async fetchpriority="low"></script>
</body>
</html>
Framework-Specific Optimizations
Next.js
// next.config.js
module.exports = {
images: {
formats: ['image/avif', 'image/webp'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
},
experimental: {
optimizeCss: true, // Critters CSS inlining
},
};
// LCP optimization with next/image
import Image from 'next/image';
function Hero() {
return (
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority // Disables lazy loading, preloads
fetchPriority="high"
/>
);
}
// Font optimization
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap',
});
// Web Vitals reporting
// pages/_app.js
export function reportWebVitals(metric) {
console.log(metric);
// Send to analytics
}
// Server Components for reduced JS
// app/page.js (Server Component by default)
async function Page() {
const data = await fetchData(); // No JS sent to client
return <div>{data.content}</div>;
}
React
// Code splitting with lazy
import { lazy, Suspense } from 'react';
const HeavyComponent = lazy(() => import('./HeavyComponent'));
function App() {
return (
<Suspense fallback={<Skeleton />}>
<HeavyComponent />
</Suspense>
);
}
// Avoid re-renders with memo
import { memo, useMemo, useCallback } from 'react';
const ExpensiveList = memo(function ExpensiveList({ items, onItemClick }) {
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => onItemClick(item.id)}>
{item.name}
</li>
))}
</ul>
);
});
// Virtualization for long lists
import { FixedSizeList } from 'react-window';
function VirtualList({ items }) {
return (
<FixedSizeList
height={500}
itemCount={items.length}
itemSize={50}
width="100%"
>
{({ index, style }) => (
<div style={style}>{items[index].name}</div>
)}
</FixedSizeList>
);
}
// Transition API for INP
import { useTransition } from 'react';
function SearchBox() {
const [isPending, startTransition] = useTransition();
const [results, setResults] = useState([]);
const handleSearch = (query) => {
startTransition(() => {
const filtered = searchItems(query); // Expensive
setResults(filtered);
});
};
return (
<>
<input onChange={(e) => handleSearch(e.target.value)} />
{isPending && <Spinner />}
<Results items={results} />
</>
);
}
Vue.js
<!-- Async component loading -->
<script setup>
import { defineAsyncComponent } from 'vue';
const HeavyChart = defineAsyncComponent(() =>
import('./HeavyChart.vue')
);
</script>
<!-- v-once for static content -->
<template>
<header v-once>
<h1>{{ siteTitle }}</h1>
</header>
</template>
<!-- Computed properties for expensive operations -->
<script setup>
import { computed } from 'vue';
const expensiveResult = computed(() => {
return items.value.filter(/* ... */).map(/* ... */);
});
</script>
<!-- Virtual scrolling -->
<script setup>
import { RecycleScroller } from 'vue-virtual-scroller';
</script>
<template>
<RecycleScroller
:items="items"
:item-size="50"
key-field="id"
>
<template #default="{ item }">
<div class="item">{{ item.name }}</div>
</template>
</RecycleScroller>
</template>
Debugging Performance Issues
Chrome DevTools Performance Panel
// Performance panel workflow:
// 1. Open DevTools → Performance tab
// 2. Click record or Cmd+Shift+E
// 3. Interact with page
// 4. Stop recording
// Key things to look for:
const performanceAnalysis = {
// Long Tasks (red bars)
longTasks: 'Tasks > 50ms that block main thread',
// Layout Shifts (purple markers)
layoutShifts: 'Click to see which elements shifted',
// LCP marker (green)
lcp: 'Shows when LCP element rendered',
// Frames section
frames: 'Red frames indicate jank (dropped frames)',
// Main thread activity
mainThread: {
yellow: 'JavaScript execution',
purple: 'Layout/rendering',
green: 'Painting',
gray: 'Idle',
},
// Bottom-up / Call Tree
analysis: 'Find expensive functions',
};
// Performance insights panel (newer)
// Automatically identifies issues:
// - LCP by phase
// - Render-blocking resources
// - Long tasks
// - Layout shifts with attribution
Lighthouse Deep Dive
// Lighthouse CLI with detailed output
// npx lighthouse https://example.com --output=json --output-path=./report.json
// Key audits to check:
const lighthouseAudits = {
// LCP related
'largest-contentful-paint': 'LCP time',
'lcp-lazy-loaded': 'Is LCP image lazy loaded? (bad)',
'prioritize-lcp-image': 'Was LCP image preloaded?',
'render-blocking-resources': 'JS/CSS blocking render',
'unused-css-rules': 'CSS that can be deferred',
'unused-javascript': 'JS that can be deferred',
// INP/TBT related
'total-blocking-time': 'Sum of blocking time',
'mainthread-work-breakdown': 'What's using main thread',
'bootup-time': 'JavaScript boot-up time',
'third-party-summary': 'Third-party script impact',
'dom-size': 'Large DOM impacts performance',
// CLS related
'cumulative-layout-shift': 'CLS score',
'layout-shift-elements': 'Which elements shifted',
'unsized-images': 'Images without dimensions',
'non-composited-animations': 'Animations causing layout',
};
// Custom Lighthouse config
// lighthouse.config.js
module.exports = {
extends: 'lighthouse:default',
settings: {
onlyCategories: ['performance'],
throttling: {
cpuSlowdownMultiplier: 4,
downloadThroughputKbps: 1600,
uploadThroughputKbps: 750,
rttMs: 150,
},
},
};
Identifying Long Tasks
// Log long tasks with details
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.group(`Long Task: ${entry.duration.toFixed(2)}ms`);
console.log('Start:', entry.startTime.toFixed(2));
console.log('Attribution:', entry.attribution);
// With Long Animation Frames API (newer)
if (entry.scripts) {
entry.scripts.forEach(script => {
console.log('Script:', script.invoker);
console.log('Source:', script.sourceURL);
console.log('Function:', script.sourceFunctionName);
});
}
console.groupEnd();
}
});
// Long Tasks API
observer.observe({ type: 'longtask', buffered: true });
// Long Animation Frames API (more detailed)
observer.observe({ type: 'long-animation-frame', buffered: true });
CLS Debugging
// Find what's causing layout shifts
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue; // Skip user-initiated
console.group(`Layout Shift: ${entry.value.toFixed(4)}`);
entry.sources?.forEach((source, i) => {
console.log(`Source ${i + 1}:`);
console.log(' Element:', source.node);
console.log(' Previous rect:', source.previousRect);
console.log(' Current rect:', source.currentRect);
// Highlight the element
if (source.node) {
source.node.style.outline = '3px solid red';
setTimeout(() => {
source.node.style.outline = '';
}, 2000);
}
});
console.groupEnd();
}
});
observer.observe({ type: 'layout-shift', buffered: true });
// Layout Instability DevTools
// Chrome DevTools → Rendering → Layout Shift Regions
// Shows blue rectangles where shifts occur
Performance Budgets
Setting Budgets
// budget.json (for Lighthouse CI)
[
{
"resourceSizes": [
{ "resourceType": "script", "budget": 300 },
{ "resourceType": "total", "budget": 500 }
],
"resourceCounts": [
{ "resourceType": "third-party", "budget": 10 }
],
"timings": [
{ "metric": "largest-contentful-paint", "budget": 2500 },
{ "metric": "total-blocking-time", "budget": 200 },
{ "metric": "cumulative-layout-shift", "budget": 0.1 }
]
}
]
// Lighthouse CI config
// lighthouserc.js
module.exports = {
ci: {
assert: {
assertions: {
'categories:performance': ['error', { minScore: 0.9 }],
'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
'total-blocking-time': ['error', { maxNumericValue: 200 }],
},
},
upload: {
target: 'lhci',
serverBaseUrl: 'https://lhci.example.com',
},
},
};
Monitoring and Alerting
// Example: DataDog RUM configuration
datadogRum.init({
applicationId: 'xxx',
clientToken: 'xxx',
site: 'datadoghq.com',
service: 'my-app',
env: 'production',
trackUserInteractions: true,
trackResources: true,
trackLongTasks: true,
});
// Set up monitors for:
// - LCP p75 > 2500ms
// - INP p75 > 200ms
// - CLS p75 > 0.1
// Example alert query (DataDog):
// avg(last_1h):avg:rum.performance.largest_contentful_paint{env:production}
// by {view.url_path} > 2500
SEO and Ranking Impact
Core Web Vitals as Ranking Signal
┌─────────────────────────────────────────────────────────────────┐
│ Google Page Experience Signals │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Core Web Vitals (Field Data) │
│ ├── LCP ≤ 2.5s │
│ ├── INP ≤ 200ms │
│ └── CLS ≤ 0.1 │
│ │
│ Other Page Experience Signals │
│ ├── HTTPS (secure connection) │
│ ├── Mobile-friendly │
│ ├── No intrusive interstitials │
│ └── Safe browsing (no malware) │
│ │
│ Important Notes: │
│ • Uses FIELD data from CrUX (not lab) │
│ • Evaluated at page-level first, then origin │
│ • One of many ranking factors (content still king) │
│ • Tiebreaker between otherwise equal pages │
│ │
└─────────────────────────────────────────────────────────────────┘
Monitoring Search Console
// Search Console API for Core Web Vitals
// GET https://searchconsole.googleapis.com/v1/urlInspection/index:inspect
// Search Console → Core Web Vitals report shows:
// - URLs grouped by status (Good, Needs Improvement, Poor)
// - Trend over time
// - Example URLs for each issue
// - Mobile vs Desktop breakdown
// Issues you might see:
const searchConsoleIssues = {
'LCP issue: longer than 2.5s': 'Optimize LCP element loading',
'LCP issue: longer than 4s': 'Critical - major optimization needed',
'CLS issue: more than 0.1': 'Fix layout shifts',
'CLS issue: more than 0.25': 'Critical - major layout issues',
'INP issue: longer than 200ms': 'Improve interaction responsiveness',
'INP issue: longer than 500ms': 'Critical - page feels unresponsive',
};
Advanced Topics
Soft Navigations (SPAs)
// Soft navigations = client-side route changes
// Chrome is experimenting with measuring Web Vitals for these
// Experimental: Soft Navigation API
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('Soft navigation:', {
name: entry.name,
startTime: entry.startTime,
duration: entry.duration,
});
}
});
observer.observe({ type: 'soft-navigation', buffered: true });
// For SPAs, consider:
// 1. Measuring virtual page views separately
// 2. Resetting CLS on route change
// 3. Tracking route-specific LCP
// 4. Monitoring INP throughout session
Back/Forward Cache (bfcache)
// bfcache allows instant back/forward navigation
// Pages restored from bfcache don't get new LCP
// Detect bfcache restoration
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
console.log('Page was restored from bfcache');
// Don't report new LCP - use cached metrics
}
});
// Things that break bfcache:
const bfcacheBlockers = [
'unload event listener',
'Cache-Control: no-store',
'Open IndexedDB connections',
'In-progress fetch requests',
'Active WebSocket connections',
];
// Check bfcache eligibility
// Chrome DevTools → Application → Back/forward cache
Prerendering and Speculation Rules
<!-- Speculation Rules API for prerendering -->
<script type="speculationrules">
{
"prerender": [
{
"source": "list",
"urls": ["/next-page", "/product/123"]
}
],
"prefetch": [
{
"source": "document",
"where": {
"href_matches": "/product/*"
},
"eagerness": "moderate"
}
]
}
</script>
<!-- Prerendered pages have ~0ms LCP for users -->
// Detect if page was prerendered
if (document.prerendering) {
console.log('Currently prerendering');
}
// Activating after prerender
document.addEventListener('prerenderingchange', () => {
if (!document.prerendering) {
console.log('Page activated from prerender');
// Start timing from activation, not prerender
}
});
Tools and Resources
Essential Tools
┌─────────────────────────────────────────────────────────────────┐
│ Web Vitals Tooling Ecosystem │
├─────────────────────────────────────────────────────────────────┤
│ │
│ MEASUREMENT (Lab) │
│ ├── Lighthouse (Chrome DevTools, CLI, CI) │
│ ├── WebPageTest (waterfall, filmstrip, comparison) │
│ ├── PageSpeed Insights (lab + field combined) │
│ └── Chrome DevTools Performance panel │
│ │
│ MEASUREMENT (Field) │
│ ├── Chrome User Experience Report (CrUX) │
│ ├── web-vitals library │
│ ├── Search Console Core Web Vitals report │
│ └── RUM solutions (Datadog, New Relic, SpeedCurve) │
│ │
│ DEBUGGING │
│ ├── Chrome DevTools (Performance, Lighthouse, Layers) │
│ ├── Web Vitals Extension │
│ ├── Layout Shift Debugger │
│ └── Performance Insights panel │
│ │
│ MONITORING │
│ ├── Lighthouse CI │
│ ├── Calibre │
│ ├── SpeedCurve │
│ └── Treo │
│ │
│ OPTIMIZATION │
│ ├── Squoosh (image optimization) │
│ ├── Bundlephobia (npm package size) │
│ ├── Import Cost (VS Code extension) │
│ └── Webpack Bundle Analyzer │
│ │
└─────────────────────────────────────────────────────────────────┘
Quick Reference Commands
# Lighthouse CLI
npx lighthouse https://example.com --output=html --view
# Lighthouse with specific settings
npx lighthouse https://example.com \
--only-categories=performance \
--throttling-method=provided \
--chrome-flags="--headless"
# Lighthouse CI
npx lhci autorun
# WebPageTest CLI
npx webpagetest test https://example.com -k YOUR_API_KEY
# Web Vitals in DevTools Console
// Paste this in console:
const script = document.createElement('script');
script.src = 'https://unpkg.com/web-vitals/dist/web-vitals.iife.js';
script.onload = () => {
webVitals.onCLS(console.log);
webVitals.onINP(console.log);
webVitals.onLCP(console.log);
};
document.head.appendChild(script);
Future of Web Vitals
Evolving Metrics
┌─────────────────────────────────────────────────────────────────┐
│ Web Vitals Evolution │
├─────────────────────────────────────────────────────────────────┤
│ │
│ PAST CHANGES │
│ ├── 2024: INP replaced FID as Core Web Vital │
│ ├── 2023: INP moved from experimental to pending │
│ └── 2022: INP introduced as experimental metric │
│ │
│ CURRENT EXPERIMENTAL METRICS │
│ ├── Time to First Byte (TTFB) - diagnostic metric │
│ ├── Soft Navigations - SPA page transitions │
│ └── Long Animation Frames - detailed main thread blocking │
│ │
│ POTENTIAL FUTURE METRICS │
│ ├── Smoothness metrics (animation jank) │
│ ├── Memory usage impact │
│ ├── Energy consumption │
│ └── Accessibility performance │
│ │
│ GUIDING PRINCIPLES │
│ ├── User-centric (measures real experience) │
│ ├── Measurable in the field │
│ ├── Actionable (developers can improve) │
│ └── Stable (doesn't change frequently) │
│ │
└─────────────────────────────────────────────────────────────────┘
Summary
┌─────────────────────────────────────────────────────────────────┐
│ Web Vitals Quick Reference │
├─────────────────────────────────────────────────────────────────┤
│ │
│ CORE WEB VITALS (2024) │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ LCP (Largest Contentful Paint) │ │
│ │ • Measures: Loading performance │ │
│ │ • Target: ≤ 2.5 seconds │ │
│ │ • Optimize: Preload, priority hints, image formats │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ INP (Interaction to Next Paint) │ │
│ │ • Measures: Interactivity │ │
│ │ • Target: ≤ 200 milliseconds │ │
│ │ • Optimize: Yield, code split, web workers │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ CLS (Cumulative Layout Shift) │ │
│ │ • Measures: Visual stability │ │
│ │ • Target: ≤ 0.1 │ │
│ │ • Optimize: Dimensions, reserved space, fonts │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ KEY PRINCIPLES │
│ • Measure in the field (real users), not just lab │
│ • Target 75th percentile, not average │
│ • All three metrics must be "good" to pass │
│ • CrUX data is used for Google ranking │
│ • Use web-vitals library for accurate measurement │
│ │
└─────────────────────────────────────────────────────────────────┘
References
- web.dev/vitals - Official documentation
- web-vitals library
- Chrome UX Report
- PageSpeed Insights
- Lighthouse
- Web Vitals Extension
Last Updated: 2024 | Metrics current as of INP becoming Core Web Vital (March 2024)
What did you think?