Google replaced FID with INP in March 2024. INP measures the worst interaction latency during a page visit — not just the first one.
Core Web Vitals 2026
| Metric | What It Measures | Good | Poor |
|---|---|---|---|
| LCP | Largest content paint time | ≤2.5s | >4s |
| INP | Worst interaction response | ≤200ms | >500ms |
| CLS | Cumulative layout shift | ≤0.1 | >0.25 |
Measuring with web-vitals Library
import { onLCP, onINP, onCLS } from 'web-vitals'
function sendToAnalytics({ name, value, id, rating, navigationType }) {
fetch('/api/vitals', {
method: 'POST',
body: JSON.stringify({
metric: name,
value: Math.round(name === 'CLS' ? value * 1000 : value),
id, rating, navigationType,
url: window.location.href,
}),
keepalive: true
})
}
onLCP(sendToAnalytics, { reportAllChanges: true })
onINP(sendToAnalytics, { reportAllChanges: true })
onCLS(sendToAnalytics, { reportAllChanges: true })
Optimizing LCP
<!-- Preload the LCP element -->
<link rel="preload" as="image" href="/hero.webp" fetchpriority="high">
<!-- LCP image: never lazy-load, always include dimensions -->
<img
src="/hero.webp"
width="1200" height="600"
alt="Hero"
fetchpriority="high"
>
<!-- fetchpriority="high" tells browser to load this ASAP -->
<!-- width/height prevent layout shift while loading -->
Optimizing INP: Break Long Tasks
INP problems come from synchronous code blocking the main thread during interactions.
// Bad: 500ms synchronous work during click
button.addEventListener('click', () => {
const result = expensiveComputation(largeData) // Blocks paint
updateUI(result)
})
// Good: yield between chunks
button.addEventListener('click', async () => {
button.disabled = true // Immediate visual feedback
const results = []
for (let i = 0; i < largeData.length; i += 100) {
results.push(...processChunk(largeData.slice(i, i + 100)))
// Yield to allow browser to paint and handle other inputs
await scheduler.yield()
// Fallback: await new Promise(resolve => setTimeout(resolve, 0))
}
updateUI(results)
button.disabled = false
})
// Heavy computation → Web Worker
const worker = new Worker('/workers/compute.js')
button.addEventListener('click', () => {
worker.postMessage({ data: largeData })
worker.onmessage = ({ data: result }) => updateUI(result)
})
Preventing CLS
/* Reserve space for images/videos */
.hero-image {
aspect-ratio: 16 / 9;
width: 100%;
object-fit: cover;
/* Browser knows height before image loads */
}
/* Reserve space for ads */
.ad-slot {
min-height: 250px;
width: 300px;
}
/* Use skeleton screens instead of showing content after load */
.skeleton-text {
height: 1em;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
Real-User Monitoring at P75
// Google measures P75 (75th percentile), not average
function calculateP75(values) {
const sorted = [...values].sort((a, b) => a - b)
return sorted[Math.floor(sorted.length * 0.75)]
}
function analyzeVitals(data) {
const byPage = {}
for (const entry of data) {
if (!byPage[entry.url]) byPage[entry.url] = []
byPage[entry.url].push(entry.lcp_value)
}
return Object.entries(byPage).map(([url, values]) => ({
url, p75_lcp: calculateP75(values), count: values.length
}))
}
Core Web Vitals are ranking factors AND user experience signals. Investing in them pays dividends in both SEO and conversion rate.
→ Analyze your page timing data with the Unix Timestamp tool.