What Are Core Web Vitals?
Core Web Vitals are a set of real-world, user-centered metrics that Google uses to quantify the experience of your site. They measure loading performance, interactivity, and visual stability. Google uses these metrics as ranking signals, so they directly affect your SEO.
+-------------------------------------------------------+
| Core Web Vitals |
| |
| +-------------+ +-------------+ +-------------+ |
| | LCP | | INP | | CLS | |
| | Loading | | Interactivity| | Visual | |
| | < 2.5s | | < 200ms | | Stability | |
| | | | | | < 0.1 | |
| +-------------+ +-------------+ +-------------+ |
| |
| +-------------+ +-------------+ |
| | FCP | | TTFB | |
| | First Paint| | Server | |
| | < 1.8s | | Response | |
| | | | < 800ms | |
| +-------------+ +-------------+ |
+-------------------------------------------------------+
Three metrics form the "Core" set:
- LCP (Largest Contentful Paint) — loading performance
- INP (Interaction to Next Paint) — interactivity responsiveness
- CLS (Cumulative Layout Shift) — visual stability
Two supplementary metrics are also tracked:
- FCP (First Contentful Paint) — perceived load speed
- TTFB (Time to First Byte) — server responsiveness
Largest Contentful Paint (LCP)
LCP measures how long it takes for the largest visible content element to render. This is typically a hero image, video thumbnail, or large text block.
Target Thresholds
+------------------+------------------+------------------+
| Good | Needs Improve | Poor |
+------------------+------------------+------------------+
| <= 2.5s | 2.5s - 4.0s | > 4.0s |
+------------------+------------------+------------------+
What Counts as the LCP Element?
The browser considers these element types:
<img>elements<image>inside<svg><video>poster images- Elements with
background-imageloaded via CSS - Block-level text elements (
<h1>,<p>,<div>with text)
Measuring LCP
// Using the PerformanceObserver API
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
const lastEntry = entries[entries.length - 1];
console.log('LCP:', lastEntry.startTime, 'ms');
console.log('Element:', lastEntry.element);
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
Common LCP Problems and Fixes
Problem 1: Slow server response
// Bad: SSR with blocking database queries
export async function getServerSideProps() {
const data = await db.query('SELECT * FROM products'); // 2s query
return { props: { data } };
}
// Good: Use ISR (Incremental Static Regeneration)
export async function getStaticProps() {
const data = await db.query('SELECT * FROM products');
return {
props: { data },
revalidate: 60, // Regenerate every 60 seconds
};
}
Problem 2: Render-blocking resources
<!-- Bad: Blocking CSS in head -->
<link rel="stylesheet" href="/styles/non-critical.css" />
<!-- Good: Preload critical CSS, defer the rest -->
<link rel="preload" href="/styles/critical.css" as="style" />
<link rel="stylesheet" href="/styles/critical.css" />
<link
rel="stylesheet"
href="/styles/non-critical.css"
media="print"
onload="this.media='all'"
/>
Problem 3: Unoptimized hero images
// Bad: Large unoptimized image
<img src="/hero-4000x2000.jpg" alt="Hero" />
// Good: Next.js Image with priority
import Image from 'next/image';
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority // Preloads the LCP image
sizes="100vw"
quality={85}
/>
Problem 4: Client-side rendering delays
// Bad: Fetching LCP content on the client
function HeroSection() {
const [hero, setHero] = useState(null);
useEffect(() => {
fetch('/api/hero').then(r => r.json()).then(setHero);
}, []);
if (!hero) return <Skeleton />;
return <h1>{hero.title}</h1>;
}
// Good: Server-side render the LCP content
export async function getStaticProps() {
const hero = await fetchHero();
return { props: { hero } };
}
function HeroSection({ hero }) {
return <h1>{hero.title}</h1>; // Available on first paint
}
LCP Optimization Checklist
LCP Budget Breakdown (target: 2.5s total)
+-----------------------+----------+
| Phase | Budget |
+-----------------------+----------+
| TTFB | 800ms |
| Resource load delay | 400ms |
| Resource load time | 800ms |
| Element render delay | 500ms |
+-----------------------+----------+
| Total | 2500ms |
+-----------------------+----------+
Interaction to Next Paint (INP)
INP replaced FID (First Input Delay) in March 2024. While FID only measured the delay of the first interaction, INP tracks the responsiveness of all interactions throughout the page lifecycle.
Target Thresholds
+------------------+------------------+------------------+
| Good | Needs Improve | Poor |
+------------------+------------------+------------------+
| <= 200ms | 200ms - 500ms | > 500ms |
+------------------+------------------+------------------+
What INP Measures
INP measures the time from when a user interacts (click, tap, keypress) to when the browser paints the next frame reflecting that interaction.
User clicks button
|
v
+------+------+--------+------+
| Input Delay | Process | Paint |
| (queued JS) | (handler)| (render)|
+------+------+--------+------+
|<------------- INP ------------->|
Measuring INP
// Using the web-vitals library
import { onINP } from 'web-vitals';
onINP((metric) => {
console.log('INP:', metric.value, 'ms');
console.log('Entries:', metric.entries);
// Each entry shows which interaction caused the delay
metric.entries.forEach((entry) => {
console.log('Target:', entry.target);
console.log('Duration:', entry.duration);
});
});
Common INP Problems and Fixes
Problem 1: Long-running event handlers
// Bad: Heavy computation in click handler blocks the main thread
button.addEventListener('click', () => {
const result = heavyComputation(data); // 300ms blocking
updateUI(result);
});
// Good: Break work into smaller chunks with scheduler
button.addEventListener('click', async () => {
// Show immediate feedback
button.textContent = 'Processing...';
// Yield to the browser between chunks
const result = await yieldingComputation(data);
updateUI(result);
});
async function yieldingComputation(data) {
const chunks = splitIntoChunks(data, 1000);
let result = [];
for (const chunk of chunks) {
result.push(...processChunk(chunk));
// Yield control back to the browser
await new Promise((resolve) => setTimeout(resolve, 0));
}
return result;
}
Problem 2: Expensive React re-renders
// Bad: Unoptimized list that re-renders everything
function ProductList({ products, onSelect }) {
return (
<ul>
{products.map((p) => (
<li key={p.id} onClick={() => onSelect(p)}>
<ExpensiveProductCard product={p} />
</li>
))}
</ul>
);
}
// Good: Memoize individual items
const ProductCard = React.memo(function ProductCard({ product, onSelect }) {
return (
<li onClick={() => onSelect(product)}>
<h3>{product.name}</h3>
<p>{product.price}</p>
</li>
);
});
function ProductList({ products, onSelect }) {
const handleSelect = useCallback((product) => {
onSelect(product);
}, [onSelect]);
return (
<ul>
{products.map((p) => (
<ProductCard key={p.id} product={p} onSelect={handleSelect} />
))}
</ul>
);
}
Problem 3: Layout thrashing
// Bad: Forces multiple layout recalculations
elements.forEach((el) => {
const height = el.offsetHeight; // Read — triggers layout
el.style.height = height * 2 + 'px'; // Write — invalidates layout
});
// Good: Batch reads, then batch writes
const heights = elements.map((el) => el.offsetHeight); // All reads
elements.forEach((el, i) => {
el.style.height = heights[i] * 2 + 'px'; // All writes
});
Cumulative Layout Shift (CLS)
CLS measures visual stability. It quantifies how much page content shifts around unexpectedly during the entire page lifecycle. Nothing frustrates users more than clicking a button only to have the page shift and accidentally clicking an ad instead.
Target Thresholds
+------------------+------------------+------------------+
| Good | Needs Improve | Poor |
+------------------+------------------+------------------+
| <= 0.1 | 0.1 - 0.25 | > 0.25 |
+------------------+------------------+------------------+
How CLS Is Calculated
CLS = Sum of (impact fraction * distance fraction) for each layout shift
Impact Fraction: % of viewport affected by the shift
Distance Fraction: distance the element moved / viewport height
Example:
An element covers 50% of the viewport and shifts down by 25%
CLS contribution = 0.50 * 0.25 = 0.125
Measuring CLS
// PerformanceObserver for layout shifts
let clsValue = 0;
let sessionValue = 0;
let sessionEntries = [];
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Only count shifts without user input
if (!entry.hadRecentInput) {
sessionEntries.push(entry);
sessionValue += entry.value;
}
}
clsValue = Math.max(clsValue, sessionValue);
console.log('Current CLS:', clsValue);
});
observer.observe({ type: 'layout-shift', buffered: true });
Common CLS Problems and Fixes
Problem 1: Images without dimensions
<!-- Bad: No dimensions — causes layout shift when image loads -->
<img src="/photo.jpg" alt="Photo" />
<!-- Good: Always set width and height -->
<img src="/photo.jpg" alt="Photo" width="800" height="600" />
<!-- Good: CSS aspect ratio -->
<style>
.image-container {
aspect-ratio: 16 / 9;
width: 100%;
}
</style>
<div class="image-container">
<img src="/photo.jpg" alt="Photo" style="width:100%; height:100%; object-fit:cover;" />
</div>
Problem 2: Dynamically injected content
// Bad: Ad loads and pushes content down
function Article() {
return (
<div>
<h1>Article Title</h1>
<AdBanner /> {/* Height unknown until ad loads */}
<p>Article content...</p>
</div>
);
}
// Good: Reserve space for dynamic content
function Article() {
return (
<div>
<h1>Article Title</h1>
<div style={{ minHeight: '250px' }}> {/* Reserved space */}
<AdBanner />
</div>
<p>Article content...</p>
</div>
);
}
Problem 3: Web fonts causing text reflow
/* Bad: Font swap causes visible text reflow */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
}
/* Good: Use font-display and size-adjust */
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: optional; /* Prevents layout shift from font swap */
size-adjust: 105%; /* Match fallback font metrics */
ascent-override: 95%;
descent-override: 20%;
line-gap-override: 0%;
}
Problem 4: Dynamic content insertion above the viewport
// Bad: Inserting notification banner pushes everything down
function showBanner(message) {
const banner = document.createElement('div');
banner.textContent = message;
document.body.prepend(banner); // Shifts entire page
}
// Good: Use CSS transform instead of affecting layout
function showBanner(message) {
const banner = document.createElement('div');
banner.textContent = message;
banner.style.cssText = `
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1000;
transform: translateY(-100%);
transition: transform 0.3s ease;
`;
document.body.appendChild(banner);
requestAnimationFrame(() => {
banner.style.transform = 'translateY(0)';
});
}
First Contentful Paint (FCP)
FCP marks the time from navigation to when the browser renders the first bit of content from the DOM. This could be text, an image, an SVG, or a canvas element.
Target Thresholds
+------------------+------------------+------------------+
| Good | Needs Improve | Poor |
+------------------+------------------+------------------+
| <= 1.8s | 1.8s - 3.0s | > 3.0s |
+------------------+------------------+------------------+
FCP vs LCP
Timeline:
|--TTFB--|--FCP--|--LCP--|
^ ^
| |
| Largest element paints (hero image, main heading)
|
First pixel of content appears (loading spinner, nav bar)
FCP answers "Is it loading?" while LCP answers "Is the main content ready?"
Measuring FCP
// Using PerformanceObserver
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name === 'first-contentful-paint') {
console.log('FCP:', entry.startTime, 'ms');
}
}
});
observer.observe({ type: 'paint', buffered: true });
// Or using the web-vitals library
import { onFCP } from 'web-vitals';
onFCP((metric) => {
console.log('FCP:', metric.value);
});
Optimizing FCP
<!-- 1. Inline critical CSS for above-the-fold content -->
<head>
<style>
/* Only styles needed for first render */
body { margin: 0; font-family: system-ui; }
.header { height: 64px; background: #1a1a2e; }
.hero { padding: 2rem; }
</style>
<!-- 2. Preconnect to critical third-party origins -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://cdn.example.com" crossorigin />
<!-- 3. Preload critical resources -->
<link rel="preload" href="/fonts/main.woff2" as="font" type="font/woff2" crossorigin />
</head>
// 4. Avoid render-blocking JavaScript
// Bad
<script src="/analytics.js"></script>
// Good
<script src="/analytics.js" defer></script>
Time to First Byte (TTFB)
TTFB measures the time between the browser requesting a page and receiving the first byte of the response from the server. It captures DNS lookup, TCP connection, TLS negotiation, and server processing time.
Target Thresholds
+------------------+------------------+------------------+
| Good | Needs Improve | Poor |
+------------------+------------------+------------------+
| <= 800ms | 800ms - 1800ms | > 1800ms |
+------------------+------------------+------------------+
TTFB Breakdown
Browser Server
| |
|------- DNS Lookup ----------->|
| |
|------- TCP Connection ------->|
| |
|------- TLS Handshake -------->|
| |
|------- HTTP Request --------->|
| |
| [ Server Processing ] |
| |
|<------ First Byte ------------|
| |
|<-------- TTFB -------------->|
Measuring TTFB
// Using Navigation Timing API
const [navigation] = performance.getEntriesByType('navigation');
const ttfb = navigation.responseStart - navigation.requestStart;
console.log('TTFB:', ttfb, 'ms');
// Breakdown
console.log('DNS:', navigation.domainLookupEnd - navigation.domainLookupStart);
console.log('TCP:', navigation.connectEnd - navigation.connectStart);
console.log('TLS:', navigation.requestStart - navigation.secureConnectionStart);
console.log('Server:', navigation.responseStart - navigation.requestStart);
Optimizing TTFB
// 1. Use a CDN — serve from edge locations near users
// 2. Enable server-side caching
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, s-maxage=60, stale-while-revalidate=300',
},
],
},
];
},
};
// 3. Avoid blocking database queries
// Bad: Sequential queries
const user = await db.getUser(id);
const posts = await db.getPosts(user.id);
const comments = await db.getComments(posts.map(p => p.id));
// Good: Parallel queries
const [user, posts] = await Promise.all([
db.getUser(id),
db.getPostsByUserId(id),
]);
const comments = await db.getComments(posts.map(p => p.id));
Measuring All Vitals with the web-vitals Library
Google's web-vitals library is the standard way to measure all Core Web Vitals in production.
Installation and Setup
npm install web-vitals
// lib/vitals.js
import { onCLS, onFCP, onINP, onLCP, onTTFB } from 'web-vitals';
function sendToAnalytics(metric) {
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating, // 'good', 'needs-improvement', 'poor'
delta: metric.delta,
id: metric.id,
navigationType: metric.navigationType,
});
// Use sendBeacon for reliability during page unload
if (navigator.sendBeacon) {
navigator.sendBeacon('/api/vitals', body);
} else {
fetch('/api/vitals', { body, method: 'POST', keepalive: true });
}
}
export function reportWebVitals() {
onCLS(sendToAnalytics);
onFCP(sendToAnalytics);
onINP(sendToAnalytics);
onLCP(sendToAnalytics);
onTTFB(sendToAnalytics);
}
Next.js Integration
// pages/_app.tsx
import { reportWebVitals } from '../lib/vitals';
export function reportWebVitals(metric) {
switch (metric.name) {
case 'LCP':
console.log('LCP:', metric.value);
break;
case 'INP':
console.log('INP:', metric.value);
break;
case 'CLS':
console.log('CLS:', metric.value);
break;
case 'FCP':
console.log('FCP:', metric.value);
break;
case 'TTFB':
console.log('TTFB:', metric.value);
break;
}
}
export default function App({ Component, pageProps }) {
return <Component {...pageProps} />;
}
Custom Reporting Dashboard
// api/vitals.js — Collect metrics server-side
export default async function handler(req, res) {
if (req.method !== 'POST') return res.status(405).end();
const metric = req.body;
// Store in your analytics database
await db.insert('web_vitals', {
name: metric.name,
value: metric.value,
rating: metric.rating,
page: req.headers.referer,
userAgent: req.headers['user-agent'],
timestamp: new Date(),
});
res.status(200).end();
}
Tools for Measuring Web Vitals
Lighthouse (Lab Data)
Lighthouse provides lab data — simulated metrics under controlled conditions.
# CLI usage
npm install -g lighthouse
lighthouse https://example.com --output html --output-path report.html
# With specific device emulation
lighthouse https://example.com \
--emulated-form-factor=mobile \
--throttling.cpuSlowdownMultiplier=4
// Programmatic Lighthouse in Node.js
import lighthouse from 'lighthouse';
import * as chromeLauncher from 'chrome-launcher';
async function runLighthouse(url) {
const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless'] });
const result = await lighthouse(url, {
port: chrome.port,
onlyCategories: ['performance'],
});
const { lhr } = result;
console.log('Performance Score:', lhr.categories.performance.score * 100);
console.log('LCP:', lhr.audits['largest-contentful-paint'].numericValue);
console.log('CLS:', lhr.audits['cumulative-layout-shift'].numericValue);
console.log('TBT:', lhr.audits['total-blocking-time'].numericValue);
await chrome.kill();
return lhr;
}
Lab Data vs Field Data
+-------------------+---------------------------+---------------------------+
| | Lab Data | Field Data (RUM) |
+-------------------+---------------------------+---------------------------+
| Source | Simulated (Lighthouse) | Real users (CrUX, RUM) |
| Environment | Controlled, consistent | Varies per user/device |
| Metrics | LCP, CLS, TBT, FCP, SI | LCP, CLS, INP, FCP, TTFB |
| INP available? | No (uses TBT instead) | Yes |
| When to use | Development, CI/CD | Production monitoring |
| Limitation | Doesn't reflect real users| Needs traffic volume |
+-------------------+---------------------------+---------------------------+
PageSpeed Insights
PageSpeed Insights combines both lab and field data.
// Programmatic access via API
const API_KEY = process.env.PAGESPEED_API_KEY;
const url = 'https://example.com';
const response = await fetch(
`https://www.googleapis.com/pagespeedonline/v5/runPagespeed?url=${url}&key=${API_KEY}&strategy=mobile`
);
const data = await response.json();
// Field data (Chrome UX Report)
const cruxMetrics = data.loadingExperience.metrics;
console.log('LCP (p75):', cruxMetrics.LARGEST_CONTENTFUL_PAINT_MS.percentile);
console.log('INP (p75):', cruxMetrics.INTERACTION_TO_NEXT_PAINT.percentile);
console.log('CLS (p75):', cruxMetrics.CUMULATIVE_LAYOUT_SHIFT_SCORE.percentile);
// Lab data (Lighthouse)
const labAudits = data.lighthouseResult.audits;
console.log('Lab LCP:', labAudits['largest-contentful-paint'].displayValue);
Real User Monitoring (RUM)
Lab tools give you a baseline, but RUM tells you what your actual users experience. The 75th percentile (p75) is what Google uses for ranking.
Why p75?
Users sorted by metric value:
|============================|=======|
0% 75% 100%
p75 means: 75% of users have a better experience than this value.
Google uses p75 because:
- Median (p50) ignores struggling users
- p95 is skewed by extreme outliers
- p75 balances between representing most users and catching problems
Building a RUM Pipeline
// Step 1: Collect metrics on the client
import { onCLS, onINP, onLCP } from 'web-vitals';
const queue = [];
function addToQueue(metric) {
queue.push({
name: metric.name,
value: metric.value,
rating: metric.rating,
path: window.location.pathname,
connection: navigator.connection?.effectiveType,
deviceMemory: navigator.deviceMemory,
timestamp: Date.now(),
});
}
onCLS(addToQueue);
onINP(addToQueue);
onLCP(addToQueue);
// Step 2: Flush on visibility change (user leaves tab)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden' && queue.length > 0) {
navigator.sendBeacon('/api/rum', JSON.stringify(queue));
queue.length = 0;
}
});
// Step 3: Aggregate and analyze server-side
function analyzeMetrics(metrics) {
const grouped = {};
for (const m of metrics) {
if (!grouped[m.name]) grouped[m.name] = [];
grouped[m.name].push(m.value);
}
for (const [name, values] of Object.entries(grouped)) {
values.sort((a, b) => a - b);
const p50 = values[Math.floor(values.length * 0.5)];
const p75 = values[Math.floor(values.length * 0.75)];
const p95 = values[Math.floor(values.length * 0.95)];
console.log(`${name}: p50=${p50} p75=${p75} p95=${p95}`);
}
}
Debugging Poor Metrics
Identifying the LCP Element
new PerformanceObserver((list) => {
const entries = list.getEntries();
const lcpEntry = entries[entries.length - 1];
// Log the actual element causing slow LCP
console.log('LCP element:', lcpEntry.element);
console.log('LCP tag:', lcpEntry.element?.tagName);
console.log('LCP src:', lcpEntry.element?.src || lcpEntry.element?.currentSrc);
console.log('LCP time:', lcpEntry.startTime);
console.log('LCP size:', lcpEntry.size);
}).observe({ type: 'largest-contentful-paint', buffered: true });
Finding Layout Shift Sources
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.hadRecentInput) continue;
console.log('Layout shift:', entry.value);
for (const source of entry.sources) {
console.log(' Element:', source.node);
console.log(' Previous rect:', source.previousRect);
console.log(' Current rect:', source.currentRect);
}
}
}).observe({ type: 'layout-shift', buffered: true });
Profiling Long Tasks (INP Debugging)
// Find tasks that block the main thread for > 50ms
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Long tasks are > 50ms
console.log('Long task:', entry.duration, 'ms');
console.log(' Start:', entry.startTime);
console.log(' Attribution:', entry.attribution);
}
}).observe({ type: 'longtask', buffered: true });
Complete Metric Summary Table
+--------+-------------------------------+----------+-----------+-----------+
| Metric | Measures | Good | Improve | Poor |
+--------+-------------------------------+----------+-----------+-----------+
| LCP | Loading (largest element) | <= 2.5s | 2.5-4.0s | > 4.0s |
| INP | Interactivity (all clicks) | <= 200ms | 200-500ms | > 500ms |
| CLS | Visual stability (shifts) | <= 0.1 | 0.1-0.25 | > 0.25 |
| FCP | First paint (any content) | <= 1.8s | 1.8-3.0s | > 3.0s |
| TTFB | Server response (first byte) | <= 800ms | 0.8-1.8s | > 1.8s |
+--------+-------------------------------+----------+-----------+-----------+
Putting It All Together — A Next.js Example
// pages/_app.tsx — Full Web Vitals integration
import type { AppProps } from 'next/app';
import { useEffect } from 'react';
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals';
function sendMetric(metric) {
const url = '/api/vitals';
const body = JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
id: metric.id,
page: window.location.pathname,
});
if (navigator.sendBeacon) {
navigator.sendBeacon(url, body);
}
}
export default function App({ Component, pageProps }: AppProps) {
useEffect(() => {
onCLS(sendMetric);
onINP(sendMetric);
onLCP(sendMetric);
onFCP(sendMetric);
onTTFB(sendMetric);
}, []);
return <Component {...pageProps} />;
}
// components/OptimizedHero.tsx — LCP-optimized hero section
import Image from 'next/image';
export function OptimizedHero({ title, subtitle, imageSrc }) {
return (
<section>
{/* Text renders immediately — no layout shift */}
<h1>{title}</h1>
<p>{subtitle}</p>
{/* Priority flag preloads LCP image */}
<Image
src={imageSrc}
alt={title}
width={1200}
height={600}
priority
sizes="(max-width: 768px) 100vw, 1200px"
style={{ width: '100%', height: 'auto' }}
/>
</section>
);
}
Key Takeaways
- Core Web Vitals (LCP, INP, CLS) directly affect Google search rankings
- LCP targets loading speed of the main content element — aim for under 2.5 seconds
- INP tracks responsiveness for every user interaction — aim for under 200 milliseconds
- CLS measures unexpected layout shifts — aim for a score below 0.1
- Lab data (Lighthouse) is for development; field data (RUM) is for production
- Google uses the 75th percentile of field data for ranking decisions
- The
web-vitalslibrary is the standard measurement tool - Preload LCP images, reserve space for dynamic content, and break up long tasks
- Monitor metrics continuously — performance is not a one-time fix