References

Core Web Vitals

LCP, INP, and CLS measurement, benchmarks, and root-cause diagnosis

Core Patterns

  • LCP Diagnosis
  • INP Diagnosis
  • CLS Diagnosis
  • Measurement Tools

LCP Diagnosis

Benchmarks

RatingLCP Value
Good≤ 2.5s
Needs improvement2.5s – 4.0s
Poor> 4.0s

Common LCP Elements

The LCP element is the largest image or text block visible in the viewport on load. Identify it in Chrome DevTools → Performance → “LCP” marker, or Lighthouse → Diagnostics.

Root Causes and Fixes

Slow server response (TTFB > 600ms)

Fix: Add SSR/SSG, use CDN, enable HTTP/2, compress responses (gzip/brotli)
Check: chrome://net-internals/#timing, WebPageTest waterfall

Render-blocking resources

<!-- ✅ Inline critical CSS; defer the rest -->
<style>/* above-fold styles */</style>
<link rel="stylesheet" href="styles.css" media="print"
      onload="this.media='all'" />
<noscript><link rel="stylesheet" href="styles.css" /></noscript>

LCP image not prioritized

<!-- ✅ fetchpriority="high" on LCP image only — one per page -->
<img src="hero.webp" fetchpriority="high"
     width="1200" height="600" alt="..." />

<!-- ❌ WRONG: fetchpriority="high" on multiple images negates effect -->

LCP image not preloaded (SSR pages)

<link rel="preload" as="image" href="hero.webp"
      imagesrcset="hero-400.webp 400w, hero-800.webp 800w"
      imagesizes="100vw" />

Unoptimized image format or size

  • Convert to WebP (30-50% smaller than JPEG) or AVIF (even smaller, less browser support)
  • Use srcset to serve correct size per viewport (see image-video.md)

INP Diagnosis

Benchmarks

RatingINP Value
Good≤ 200ms
Needs improvement200ms – 500ms
Poor> 500ms

INP replaced FID in March 2024. It measures all interactions (click, keydown, tap) across the page lifetime — the 98th percentile latency.

Interaction Phases

Input delay → Processing time → Presentation delay
   │                │                  │
Blocked by      JS handler         Rendering +
other tasks     execution          paint

Reduce each phase separately.

Root Causes and Fixes

Long tasks blocking the main thread

// ✅ Yield to browser between chunks of work
async function processLargeList(items) {
  for (let i = 0; i < items.length; i++) {
    processItem(items[i]);
    // Yield every 50 items
    if (i % 50 === 0) {
      await scheduler.yield(); // Chrome 115+ | fallback: setTimeout(0)
    }
  }
}

// Fallback for older browsers
function yieldToMain() {
  return new Promise(resolve => setTimeout(resolve, 0));
}

Heavy event handler

// ❌ WRONG: Synchronous heavy computation in click handler
button.addEventListener('click', () => {
  const result = runExpensiveCalculation(); // blocks UI
  updateUI(result);
});

// ✅ CORRECT: Defer heavy work, update UI immediately
button.addEventListener('click', () => {
  updateUI({ loading: true }); // immediate feedback
  setTimeout(() => {
    const result = runExpensiveCalculation();
    updateUI(result);
  }, 0);
});

Move CPU-intensive work to Web Worker

// worker.js
self.onmessage = ({ data }) => {
  const result = heavyComputation(data);
  self.postMessage(result);
};

// main.js
const worker = new Worker('./worker.js');
worker.postMessage(inputData);
worker.onmessage = ({ data }) => updateUI(data);

Input debouncing for search/filter

import { useDeferredValue } from 'react';

function SearchResults({ query }) {
  const deferredQuery = useDeferredValue(query); // React 18
  // render with deferredQuery — won't block input
}

CLS Diagnosis

Benchmarks

RatingCLS Score
Good≤ 0.1
Needs improvement0.1 – 0.25
Poor> 0.25

CLS = sum of (impact fraction × distance fraction) for each unexpected layout shift.

Root Causes and Fixes

Images without explicit dimensions

<!-- ❌ WRONG: Browser doesn't reserve space, causes shift when loaded -->
<img src="product.jpg" alt="Product" />

<!-- ✅ CORRECT: Browser reserves exact space before image loads -->
<img src="product.webp" width="800" height="600" alt="Product" />

<!-- ✅ ALSO CORRECT: aspect-ratio CSS to reserve space responsively -->
<style>
  .product-image { aspect-ratio: 4/3; width: 100%; }
</style>
<img class="product-image" src="product.webp" alt="Product" />

Font swap causing text reflow

/* ❌ WRONG: block/auto cause invisible text and potential reflow */
@font-face { font-display: block; }

/* ✅ CORRECT for most cases: visible fallback, swap when loaded */
@font-face { font-display: swap; }

/* ✅ BEST for minimal CLS: use fallback permanently if not cached */
@font-face { font-display: optional; }

Add size-adjust, ascent-override, descent-override to reduce shift between fallback and custom font:

@font-face {
  font-family: 'FallbackInter';
  src: local('Arial');
  size-adjust: 107%;
  ascent-override: 90%;
}

Dynamic content inserted above existing content

/* ✅ Reserve space for banners/ads before they load */
.ad-slot { min-height: 90px; }
.cookie-banner { min-height: 60px; }

Animations that affect layout properties

/* ❌ WRONG: animating layout properties triggers reflow */
.slide-in { animation: slideIn 0.3s; }
@keyframes slideIn { from { height: 0; } to { height: 200px; } }

/* ✅ CORRECT: animate transform/opacity only (compositor-only) */
.slide-in { animation: slideIn 0.3s; }
@keyframes slideIn { from { transform: translateY(-100%); opacity: 0; }
                     to   { transform: translateY(0);    opacity: 1; } }

Measurement Tools

Local Measurement

// web-vitals library — report in production
import { onLCP, onINP, onCLS, onTTFB, onFCP } from 'web-vitals';

function sendToAnalytics({ name, value, id, rating }) {
  // Send to your analytics endpoint
  fetch('/analytics', {
    method: 'POST',
    body: JSON.stringify({ name, value, id, rating }),
  });
}

onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);

DevTools Workflow

1. Chrome DevTools → Performance tab
2. Check "Web Vitals" checkbox → shows LCP, CLS, INP markers
3. Click LCP marker → highlights element, shows sub-parts (TTFB, load delay, load time, render delay)
4. Click INP marker → shows interaction handler duration breakdown

Lighthouse CI (automated)

# .github/workflows/lighthouse.yml
- name: Run Lighthouse CI
  uses: treosh/lighthouse-ci-action@v10
  with:
    urls: 'https://example.com/'
    budgetPath: './budget.json'
    uploadArtifacts: true
// budget.json — fail CI if scores drop
{
  "performance": 90,
  "accessibility": 100,
  "best-practices": 95,
  "seo": 95
}

Common Pitfalls

  • Measuring in dev mode: always profile production builds (npm run build && npm run preview)
  • Simulated throttling: DevTools CPU 4x slowdown simulates a mid-range mobile device — use it
  • INP in SPAs: route transitions add interaction latency; measure after full app load
  • CLS after hydration: SSR apps may shift on hydration if server/client markup differs