Skip to content

Core Web Vitals & Lighthouse

FieldValue
TypeAgent Reference
Source~/.copilot/agents/_refs/react-perf-auditor/web-vitals.md
DescriptionNot specified

Source Content

Core Web Vitals & Lighthouse

Thresholds

MetricGoodNeeds ImprovementPoor
LCP (Largest Contentful Paint)< 2.5s2.5–4s> 4s
INP (Interaction to Next Paint)< 200ms200–500ms> 500ms
CLS (Cumulative Layout Shift)< 0.10.1–0.25> 0.25
FCP (First Contentful Paint)< 1.8s1.8–3s> 3s
TTFB (Time to First Byte)< 800ms800ms–1.8s> 1.8s

Target: all metrics in Good before shipping. Lighthouse score ≥ 90 on Performance, Accessibility, Best Practices.

Running Lighthouse

Terminal window
# Install once
pnpm add -D lighthouse
# Run against local dev server
npx 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 load

For CI, use @lhci/cli (Lighthouse CI):

Terminal window
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:

  1. Open Chrome DevTools → Performance panel
  2. Click “Record” → interact with the page (clicks, key presses, form inputs) → stop
  3. Look for “Long tasks” (red bars) in the Main thread
  4. 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 input
import { 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 LCP
new 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 priority prop (Next.js) or fetchpriority="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 width and height attributes
  • 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)