Core Web Vitals & Lighthouse
| Field | Value |
|---|---|
| Type | Agent Reference |
| Source | ~/.copilot/agents/_refs/react-perf-auditor/web-vitals.md |
| Description | Not specified |
Source Content
Core Web Vitals & Lighthouse
Thresholds
| Metric | Good | Needs Improvement | Poor |
|---|---|---|---|
| LCP (Largest Contentful Paint) | < 2.5s | 2.5–4s | > 4s |
| INP (Interaction to Next Paint) | < 200ms | 200–500ms | > 500ms |
| CLS (Cumulative Layout Shift) | < 0.1 | 0.1–0.25 | > 0.25 |
| FCP (First Contentful Paint) | < 1.8s | 1.8–3s | > 3s |
| TTFB (Time to First Byte) | < 800ms | 800ms–1.8s | > 1.8s |
Target: all metrics in Good before shipping. Lighthouse score ≥ 90 on Performance, Accessibility, Best Practices.
Running Lighthouse
# Install oncepnpm add -D lighthouse
# Run against local dev servernpx lighthouse http://localhost:4321 \ --output=json \ --output-path=./lighthouse-report.json \ --only-categories=performance,accessibility,best-practices \ --chrome-flags="--headless"
# Or use the Chrome DevTools panel — Lighthouse tab → Analyze page loadFor CI, use @lhci/cli (Lighthouse CI):
pnpm add -D @lhci/cli.lighthouserc.json:
{ "ci": { "collect": { "url": ["http://localhost:4321", "http://localhost:4321/cases"], "numberOfRuns": 3 }, "assert": { "assertions": { "categories:performance": ["error", { "minScore": 0.9 }], "categories:accessibility": ["error", { "minScore": 0.9 }], "first-contentful-paint": ["error", { "maxNumericValue": 2000 }], "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }], "interactive": ["error", { "maxNumericValue": 3500 }], "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }] } } }}Measuring INP (Interaction to Next Paint)
INP replaced FID in 2024. It measures the worst interaction latency across the page lifetime. To diagnose:
- Open Chrome DevTools → Performance panel
- Click “Record” → interact with the page (clicks, key presses, form inputs) → stop
- Look for “Long tasks” (red bars) in the Main thread
- Find the interaction that caused the long task
Common INP culprits in React apps:
- Heavy re-renders on every keystroke (use
useDeferredValue) - Non-virtualized long lists re-rendering on filter change
- Synchronous state updates blocking paint
- Third-party scripts blocking the main thread
// FIX: Defer expensive renders triggered by fast inputimport { useDeferredValue, useState } from 'react'
function SearchResults() { const [query, setQuery] = useState('') const deferredQuery = useDeferredValue(query)
// This expensive computation uses the deferred (lagged) value // so it doesn't block the input from updating immediately const results = useFilteredResults(deferredQuery)
return ( <> <input value={query} onChange={(e) => setQuery(e.target.value)} /> <ResultList results={results} isPending={query !== deferredQuery} /> </> )}Measuring LCP
LCP is the render time of the largest image or text block visible in the viewport.
Diagnose:
// In DevTools Console — measure LCPnew PerformanceObserver((list) => { list.getEntries().forEach((entry) => { console.log('LCP:', entry.startTime, entry.element) })}).observe({ type: 'largest-contentful-paint', buffered: true })Common LCP culprits:
- Hero image not preloaded (
<link rel="preload">) - Image in wrong format (use WebP/AVIF)
- Image missing
priorityprop (Next.js) orfetchpriority="high"(native) - Server taking too long (TTFB > 600ms)
- Render-blocking CSS or JS
<!-- Preload the LCP image --><link rel="preload" as="image" href="/hero.webp" fetchpriority="high" /><!-- In Astro — the hero image --><img src="/hero.webp" alt="..." width="1440" height="640" fetchpriority="high" loading="eager"/>Measuring CLS
CLS measures unexpected layout shifts. Common causes:
- Images without
widthandheightattributes - Ads or embeds that appear after content
- Web fonts causing FOUT (flash of unstyled text)
- Dynamically injected content above existing content
<!-- Always set width and height on images to prevent CLS --><img src="/photo.jpg" alt="..." width="800" height="600" />/* Reserve space for font loading to prevent FOUT */@font-face { font-family: 'Fraunces Variable'; src: url('/fonts/fraunces.woff2') format('woff2'); font-display: optional; /* prevents FOUT — may fall back to system font on slow connections */}Web Vitals in Production
Install the web-vitals package to measure real user performance:
import { onCLS, onINP, onLCP, onFCP, onTTFB } from 'web-vitals'
function sendToAnalytics(metric: { name: string; value: number; rating: string }) { // Send to your analytics endpoint fetch('/api/vitals', { method: 'POST', body: JSON.stringify(metric), headers: { 'Content-Type': 'application/json' }, })}
onCLS(sendToAnalytics)onINP(sendToAnalytics)onLCP(sendToAnalytics)onFCP(sendToAnalytics)onTTFB(sendToAnalytics)