|$ curl https://forge-ai.dev/api/markdown?path=docs/performance/core-web-vitals
$cat docs/core-web-vitals.md
updated Recently·35 min read·published
Core Web Vitals
Introduction
Core Web Vitals (CWV) are three metrics Google uses to measure real-world user experience. They directly impact SEO rankings and are the industry standard for web performance measurement.
| Metric | Measures | Good | Poor |
|---|---|---|---|
| LCP | Largest Contentful Paint (loading) | < 2.5s | > 4.0s |
| INP | Interaction to Next Paint (responsiveness) | < 200ms | > 500ms |
| CLS | Cumulative Layout Shift (visual stability) | < 0.1 | > 0.25 |
LCP — Largest Contentful Paint
LCP measures when the largest visible content element (hero image, heading, video poster) finishes painting. Common LCP elements: <img>, <video>, <h1>, background images.
lcp.ts
TypeScript
| 1 | // Measure LCP with web-vitals library |
| 2 | import { onLCP } from "web-vitals"; |
| 3 | |
| 4 | onLCP((metric) => { |
| 5 | console.log("LCP:", metric.value, "element:", metric.element); |
| 6 | // Send to analytics |
| 7 | sendToAnalytics({ |
| 8 | metric: "LCP", |
| 9 | value: metric.value, |
| 10 | rating: metric.rating, // "good" | "needs-improvement" | "poor" |
| 11 | element: metric.element?.tagName, |
| 12 | }); |
| 13 | }); |
| 14 | |
| 15 | // Optimization: preload LCP resource |
| 16 | // In <head>: |
| 17 | // <link rel="preload" as="image" href="/hero.jpg" fetchpriority="high" /> |
| 18 | |
| 19 | // Optimization: use fetchpriority="high" on LCP image |
| 20 | // <img src="/hero.jpg" fetchpriority="high" alt="Hero" /> |
| 21 | |
| 22 | // Optimization: avoid lazy-loading the LCP image |
| 23 | // <img src="/hero.jpg" loading="eager" alt="Hero" /> |
| 24 | // NOT: <img src="/hero.jpg" loading="lazy" alt="Hero" /> |
| 25 | |
| 26 | // Optimization: use responsive images |
| 27 | // <img src="/hero-800.jpg" |
| 28 | // srcset="/hero-400.jpg 400w, /hero-800.jpg 800w, /hero-1200.jpg 1200w" |
| 29 | // sizes="(max-width: 768px) 100vw, 50vw" |
| 30 | // fetchpriority="high" alt="Hero" /> |
INP — Interaction to Next Paint
INP measures the latency of all interactions (clicks, taps, key presses) throughout the page lifecycle. It replaced FID in March 2024. INP captures the worst-case interaction latency, not just the first one.
inp.ts
TypeScript
| 1 | // Measure INP |
| 2 | import { onINP } from "web-vitals"; |
| 3 | |
| 4 | onINP((metric) => { |
| 5 | console.log("INP:", metric.value, "entries:", metric.entries.length); |
| 6 | sendToAnalytics({ |
| 7 | metric: "INP", |
| 8 | value: metric.value, |
| 9 | rating: metric.rating, |
| 10 | }); |
| 11 | }); |
| 12 | |
| 13 | // Optimization: break up long tasks |
| 14 | // Bad: blocking main thread for 300ms |
| 15 | function processLargeList(items: Item[]) { |
| 16 | for (const item of items) { |
| 17 | processItem(item); // Blocks main thread |
| 18 | } |
| 19 | } |
| 20 | |
| 21 | // Good: use scheduler.yield() or requestIdleCallback |
| 22 | async function processLargeList(items: Item[]) { |
| 23 | for (let i = 0; i < items.length; i++) { |
| 24 | processItem(items[i]); |
| 25 | // Yield to main thread every 5ms |
| 26 | if (i % 100 === 0) { |
| 27 | await new Promise((r) => setTimeout(r, 0)); |
| 28 | } |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | // Optimization: use startTransition for non-urgent updates |
| 33 | import { startTransition } from "react"; |
| 34 | |
| 35 | function handleClick() { |
| 36 | startTransition(() => { |
| 37 | setFilteredResults(computeFilter()); // Non-urgent |
| 38 | }); |
| 39 | } |
CLS — Cumulative Layout Shift
cls.ts
TypeScript
| 1 | // Measure CLS |
| 2 | import { onCLS } from "web-vitals"; |
| 3 | |
| 4 | onCLS((metric) => { |
| 5 | console.log("CLS:", metric.value); |
| 6 | sendToAnalytics({ metric: "CLS", value: metric.value, rating: metric.rating }); |
| 7 | }); |
| 8 | |
| 9 | // Fix: always set width/height on images |
| 10 | // <img src="photo.jpg" width="800" height="600" alt="..." /> |
| 11 | // CSS: img { aspect-ratio: 800/600; } |
| 12 | |
| 13 | // Fix: reserve space for ads/embeds |
| 14 | // .ad-container { min-height: 250px; } |
| 15 | |
| 16 | // Fix: use CSS aspect-ratio instead of padding hacks |
| 17 | // .video-wrapper { aspect-ratio: 16/9; width: 100%; } |
| 18 | |
| 19 | // Fix: use font-display: optional for web fonts |
| 20 | // @font-face { |
| 21 | // font-family: 'CustomFont'; |
| 22 | // src: url('/font.woff2') format('woff2'); |
| 23 | // font-display: optional; // No layout shift — uses fallback if font isn't ready |
| 24 | // } |
| 25 | |
| 26 | // Fix: avoid inserting content above existing content |
| 27 | // Use skeleton screens or reserve space |
✓
best practice
Always specify width and height on <img> and <video> elements. The browser uses these to calculate aspect ratio before the image loads, preventing layout shifts.
Field vs Lab Data
| Type | Source | Use For |
|---|---|---|
| Field (RUM) | Real users, real devices | Production monitoring, SEO impact |
| Lab | Simulated environment (Lighthouse) | Development, debugging, CI |
field-vs-lab.sh
Bash
| 1 | # Lab testing with Lighthouse CLI |
| 2 | npx lighthouse https://example.com --output=json --output-path=./report.json |
| 3 | |
| 4 | # CI integration |
| 5 | npx lighthouse-ci autorun --config=./lighthouserc.json |
| 6 | |
| 7 | # lighthouserc.json: |
| 8 | # { "ci": { "collect": { "url": ["http://localhost:3000"] }, |
| 9 | # "assert": { "assertions": { "categories:performance": ["error", { "minScore": 0.9 }] } } } } |
| 10 | |
| 11 | # Field data: Chrome UX Report (CrUX) |
| 12 | # https://developer.chrome.com/docs/crux/ — real user data |
| 13 | # Also available via PageSpeed Insights API |
$Blueprint — Engineering Documentation·Section ID: PERF-CWV-01·Revision: 1.0