Core Web Vitals & Loading Performance
Core Web Vitals & Loading Performance LCP, INP, CLS — what Google ranks on, what each one measures, and the concrete levers to move them. The Three Vitals Metri…
Core Web Vitals & Loading Performance
LCP, INP, CLS — what Google ranks on, what each one measures, and the concrete levers to move them.
The Three Vitals
Metric Measures Good Needs Poor
────── ────────────────────────────── ────── ─────── ──────
LCP Largest contentful paint ≤ 2.5s ≤ 4.0s > 4.0s
INP Interaction to next paint ≤ 200ms ≤ 500ms > 500ms
CLS Cumulative layout shift ≤ 0.1 ≤ 0.25 > 0.25
LCP = when the biggest above-the-fold image/text is painted.
INP = worst slow interaction the user experienced on the page.
CLS = total layout shift score (unexpected element movement).
These are p75 across all page loads — fix the long tail, not the median.Improving LCP
<!-- Preload the hero image (so it starts downloading with the HTML) -->
<link rel="preload" as="image" href="/hero.webp"
fetchpriority="high" imagesrcset="/hero.webp 1x, /hero@2x.webp 2x">
<!-- Preconnect to origins you'll hit immediately -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<!-- The LCP image itself: priority loading, no lazy attribute -->
<img src="/hero.webp" alt="" fetchpriority="high" decoding="async"
width="1200" height="600">
<!-- DON'T lazy-load above-the-fold images — defers LCP -->
<img src="/below-the-fold.webp" loading="lazy" decoding="async"
width="800" height="400">Inline critical CSS in <head>, defer the rest. Avoids render-blocking stylesheet round-trips.
Serve LCP image in modern format (AVIF/WebP) and right size. Next.js <Image> handles this; Astro/SvelteKit have equivalents.
Move the hero out of client-side render. SSR/SSG = HTML already contains it; SPA = blank screen until JS hydrates.
Cache static assets aggressively (1y immutable). Use a CDN. Origin TTFB is part of LCP.
Improving INP (replaces FID since 2024)
// INP is dominated by long tasks (> 50ms) on the main thread during input.
// Three levers: less work, smaller work, smarter scheduling.
// 1) Break long tasks with scheduler.yield() or setTimeout(0)
async function processMany(items) {
for (const item of items) {
handle(item)
if ('scheduler' in window && 'yield' in scheduler) {
await scheduler.yield() // yields to user input, then resumes
}
}
}
// 2) Defer non-critical work with requestIdleCallback
requestIdleCallback(() => trackAnalytics(), {timeout: 2000})
// 3) Move pure computation off the main thread
const worker = new Worker('/parse.js')
worker.postMessage(largeJson)
worker.onmessage = e => render(e.data)
// 4) Debounce expensive input handlers
const debouncedSearch = debounce(query => api.search(query), 200)
input.addEventListener('input', e => debouncedSearch(e.target.value))
// 5) Use CSS transitions instead of JS animation on the main thread.
// `transform` + `opacity` are GPU-accelerated; `width`/`top` are not.Improving CLS
<!-- Always declare width/height on media — reserves space before load -->
<img src="avatar.jpg" width="48" height="48" alt="">
<iframe src="..." width="640" height="360"></iframe>
<!-- Use aspect-ratio for fluid layouts -->
<style>
.card-image { aspect-ratio: 16 / 9; width: 100%; object-fit: cover; }
</style>
<!-- Preload custom fonts; use font-display: optional to avoid swap shift -->
<link rel="preload" as="font" type="font/woff2"
href="/fonts/inter.woff2" crossorigin>
<style>
@font-face {
font-family: 'Inter';
src: url('/fonts/inter.woff2') format('woff2');
font-display: optional; /* no FOIT/FOUT shift; falls back if slow */
}
</style>Reserve space for late-arriving content (ads, embeds, banners). Don't inject above existing content.
Avoid `display: none` → DOM insert flicker patterns. Render server-side; hide with CSS until ready.
Animations: use `transform` and `opacity`. Layout-triggering properties (top, left, width, height) cause shift.
Measuring
// Field data > lab data. Use the web-vitals library to capture real users.
import {onLCP, onINP, onCLS} from 'web-vitals'
onLCP(metric => send('LCP', metric.value))
onINP(metric => send('INP', metric.value))
onCLS(metric => send('CLS', metric.value))
// Tools cheat-sheet:
// Lighthouse — lab synthetic, good for diffs but optimistic
// PageSpeed Insights — Lighthouse + CrUX field data
// Chrome DevTools — Performance panel, breakdown by long tasks
// web-vitals.js — ship to your analytics for p75 trend
// Search Console — GSC > Core Web Vitals — Google's view of your URLs