Back

Measuring and Optimizing Core Web Vitals on High-Traffic Pages

· 11 min read

Core Web Vitals matter more on high-traffic pages. A 100ms regression on a page serving 10 million users per month affects real people at scale. This guide covers how to measure, monitor, and optimize LCP, INP, and CLS in production environments where the stakes are high.

The Three Metrics That Matter

Google’s Core Web Vitals focus on three user experience signals:

MetricWhat It MeasuresGoodNeeds WorkPoor
LCP (Largest Contentful Paint)Loading performance≤2.5s≤4.0s>4.0s
INP (Interaction to Next Paint)Responsiveness (p98)≤200ms≤500ms>500ms
CLS (Cumulative Layout Shift)Visual stability≤0.1≤0.25>0.25

INP replaced FID (First Input Delay) in March 2024. It’s harder to optimize because it measures all interactions throughout the page lifecycle, not just the first one. Google aggregates INP at the 98th percentile, meaning your worst interactions (excluding outliers) define your score.

Setting Up Real User Monitoring (RUM)

Lab tools like Lighthouse are useful for development, but production performance requires Real User Monitoring. Your p75 in the field will differ from your local measurements.

Using the web-vitals Library

// lib/web-vitals.ts
import { onLCP, onINP, onCLS, type Metric } from 'web-vitals';

interface VitalMetric {
  name: string;
  value: number;
  rating: 'good' | 'needs-improvement' | 'poor';
  delta: number;
  id: string;
  navigationType: 'navigate' | 'reload' | 'back-forward' | 'back-forward-cache' | 'prerender';
}

function sendToAnalytics(metric: VitalMetric) {
  // Send to your analytics endpoint
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    delta: metric.delta,
    id: metric.id,
    page: window.location.pathname,
    // Include attribution for debugging
    navigationType: metric.navigationType,
    timestamp: Date.now(),
  });

  // Use sendBeacon for reliability on page unload
  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/vitals', body);
  } else {
    fetch('/api/vitals', { body, method: 'POST', keepalive: true });
  }
}

export function initWebVitals() {
  onLCP(sendToAnalytics);
  onINP(sendToAnalytics);
  onCLS(sendToAnalytics);
}

Attribution for Debugging

The web-vitals library provides attribution data that tells you why a metric is slow:

import { onLCP, onINP, onCLS } from 'web-vitals/attribution';

onLCP((metric) => {
  console.log('LCP element:', metric.attribution.element);
  console.log('Resource load time:', metric.attribution.resourceLoadTime);
  console.log('Time to first byte:', metric.attribution.timeToFirstByte);
});

onINP((metric) => {
  console.log('Target element:', metric.attribution.interactionTarget);
  console.log('Input delay:', metric.attribution.inputDelay);
  console.log('Processing time:', metric.attribution.processingTime);
  console.log('Presentation delay:', metric.attribution.presentationDelay);
});

onCLS((metric) => {
  console.log('Largest shift target:', metric.attribution.largestShiftTarget);
  console.log('Largest shift time:', metric.attribution.largestShiftTime);
});

This attribution data is invaluable. When INP spikes, you’ll know exactly which element and handler caused it.

Optimizing LCP

LCP measures when the largest content element becomes visible. On most pages, this is a hero image, headline, or main content block.

1. Identify Your LCP Element

First, find out what your LCP element actually is:

// Debug script - remove in production
new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const lastEntry = entries[entries.length - 1];
  console.log('LCP candidate:', lastEntry.element);
  console.log('LCP time:', lastEntry.startTime);
}).observe({ type: 'largest-contentful-paint', buffered: true });

Common LCP elements:

  • Hero images
  • <h1> text
  • Background images on hero sections
  • Video poster images

2. Preload Critical Resources

If your LCP is an image, preload it:

<head>
  <!-- Preload hero image -->
  <link
    rel="preload"
    href="/hero.webp"
    as="image"
    fetchpriority="high"
  />

  <!-- Preconnect to image CDN -->
  <link rel="preconnect" href="https://cdn.example.com" />
</head>

The fetchpriority="high" attribute tells the browser to prioritize this resource over others. Use it on your LCP image (both in <link rel="preload"> and on the <img> tag itself). Don’t overuse it—if everything is high priority, nothing is.

Preload vs Prefetch: preload fetches resources needed for the current page immediately. prefetch hints at resources for future navigations and loads them at low priority during idle time. For LCP, always use preload.

For dynamic images (e.g., from a CMS), the pattern is framework-specific. Here’s a Next.js example:

// Next.js example
export default async function Page() {
  const heroImage = await getHeroImage();

  return (
    <>
      <link
        rel="preload"
        href={heroImage.url}
        as="image"
      />
      <img src={heroImage.url} alt={heroImage.alt} fetchpriority="high" />
    </>
  );
}

3. Optimize Server Response Time (TTFB)

LCP can’t start until the HTML arrives. If TTFB is slow, everything is slow.

// Measure TTFB
const [navigation] = performance.getEntriesByType('navigation');
console.log('TTFB:', navigation.responseStart - navigation.requestStart);

Common TTFB fixes:

  • Use a CDN for HTML (Vercel, Cloudflare, etc.)
  • Implement stale-while-revalidate caching
  • Reduce server-side rendering complexity
  • Use edge rendering for personalized content

4. Avoid LCP Invalidation

The browser can change its mind about what the LCP element is. If a larger element renders later, LCP is re-measured. Avoid patterns like:

// Bad: Hero image loads after text, becomes new LCP
function Hero() {
  const [image, setImage] = useState(null);

  useEffect(() => {
    loadHeroImage().then(setImage); // LCP invalidated when this loads
  }, []);

  return (
    <div>
      <h1>Welcome</h1> {/* Initial LCP */}
      {image && <img src={image} />} {/* New LCP - worse! */}
    </div>
  );
}

// Good: Image is part of initial render
function Hero({ image }) {
  return (
    <div>
      <h1>Welcome</h1>
      <img src={image} fetchpriority="high" />
    </div>
  );
}

Optimizing INP

INP (Interaction to Next Paint) replaced FID in 2024. It measures the delay between user interaction and visual update for all interactions, taking the worst one (at p98).

Understanding INP Components

INP = Input Delay + Processing Time + Presentation Delay

User clicks → [Input Delay] → Handler starts → [Processing Time] → Handler ends → [Presentation Delay] → Paint

Each phase requires different optimizations.

1. Reduce Input Delay

Input delay occurs when the main thread is busy when the user interacts. Long tasks block input processing.

// Bad: Long synchronous task blocks input
function handleSearch(query: string) {
  const results = heavyFilterOperation(data, query); // 200ms blocking
  setResults(results);
}

// Good: Yield to main thread
async function handleSearch(query: string) {
  // Let browser handle pending interactions
  await scheduler.yield?.() ?? new Promise(r => setTimeout(r, 0));

  const results = heavyFilterOperation(data, query);
  setResults(results);
}

Use scheduler.yield() to break up long tasks. The fallback pattern using setTimeout(r, 0) works but has a ~4ms minimum delay clamped by browsers. For more precise scheduling, scheduler.postTask() offers priority-based task scheduling, though browser support is still evolving.

2. Reduce Processing Time

The event handler itself should be fast:

// Bad: Computing everything in the handler
function handleAddToCart(productId: string) {
  const product = products.find(p => p.id === productId);
  const updatedCart = [...cart, product];
  const newTotal = updatedCart.reduce((sum, p) => sum + p.price, 0);
  const formattedTotal = formatCurrency(newTotal);
  const taxAmount = calculateTax(newTotal);
  // ...more computation

  setCart(updatedCart);
  setTotal(formattedTotal);
  setTax(taxAmount);
}

// Good: Minimal work in handler, derive in render
function handleAddToCart(productId: string) {
  setCart(prev => [...prev, products.get(productId)]);
}

// Derived values computed during render (or useMemo)
const total = useMemo(() => cart.reduce((sum, p) => sum + p.price, 0), [cart]);

3. Reduce Presentation Delay

Presentation delay is the time between handler completion and paint. Large React trees cause long presentation delays.

// Bad: Re-rendering massive list on every interaction
function ProductList({ products, filter }) {
  const filtered = products.filter(p => p.category === filter);

  return (
    <div>
      {filtered.map(p => <ProductCard key={p.id} product={p} />)}
    </div>
  );
}

// Good: Virtualize large lists
import { useVirtualizer } from '@tanstack/react-virtual';

function ProductList({ products, filter }) {
  const filtered = useMemo(
    () => products.filter(p => p.category === filter),
    [products, filter]
  );

  const virtualizer = useVirtualizer({
    count: filtered.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 120,
  });

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize() }}>
        {virtualizer.getVirtualItems().map(virtualItem => (
          <ProductCard
            key={filtered[virtualItem.index].id}
            product={filtered[virtualItem.index]}
            style={{ transform: `translateY(${virtualItem.start}px)` }}
          />
        ))}
      </div>
    </div>
  );
}

4. Use Transitions for Non-Urgent Updates

React 18’s useTransition marks updates as non-urgent, allowing the browser to stay responsive:

function SearchResults() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;

    // Urgent: Update input immediately
    setQuery(value);

    // Non-urgent: Filter results can wait
    startTransition(() => {
      setResults(filterProducts(value));
    });
  }

  return (
    <div>
      <input value={query} onChange={handleChange} />
      {isPending && <Spinner />}
      <ResultsList results={results} />
    </div>
  );
}

Optimizing CLS

CLS measures unexpected layout shifts. It’s the metric users feel most viscerally.

1. Reserve Space for Dynamic Content

/* Bad: Image has no dimensions, causes shift when loaded */
img {
  max-width: 100%;
}

/* Good: Aspect ratio reserves space */
.hero-image {
  aspect-ratio: 16 / 9;
  width: 100%;
  object-fit: cover;
}

For images with unknown dimensions, use aspect-ratio containers:

function DynamicImage({ src, alt }: { src: string; alt: string }) {
  return (
    <div className="relative w-full aspect-video bg-neutral-200">
      <img
        src={src}
        alt={alt}
        className="absolute inset-0 w-full h-full object-cover"
        loading="lazy"
      />
    </div>
  );
}

2. Avoid Injecting Content Above Existing Content

// Bad: Banner pushes content down
function Page() {
  const [showBanner, setShowBanner] = useState(false);

  useEffect(() => {
    checkPromotion().then(setShowBanner);
  }, []);

  return (
    <main>
      {showBanner && <PromoBanner />} {/* Shifts everything down */}
      <Content />
    </main>
  );
}

// Good: Reserve space or use overlay
function Page() {
  const [showBanner, setShowBanner] = useState(false);

  useEffect(() => {
    checkPromotion().then(setShowBanner);
  }, []);

  return (
    <main>
      <div className="h-12"> {/* Reserved space */}
        {showBanner && <PromoBanner />}
      </div>
      <Content />
    </main>
  );
}

3. Use CSS contain for Complex Components

The contain property limits the scope of browser layout calculations:

.card {
  contain: layout style paint;
}

.sidebar {
  contain: strict; /* Most aggressive containment */
}

4. Font Loading Strategy

Web fonts cause layout shifts when they swap in with different metrics:

@font-face {
  font-family: 'Custom Font';
  src: url('/font.woff2') format('woff2');
  font-display: optional; /* No shift - uses fallback if font is slow */
}

Or use font-display: swap with metric overrides to match your fallback font:

@font-face {
  font-family: 'Custom Font';
  src: url('/font.woff2') format('woff2');
  font-display: swap;
  size-adjust: 105%; /* Match fallback font metrics */
  ascent-override: 90%;
  descent-override: 20%;
}

Calculating these values manually is tedious. Tools like Fontaine automatically generate metric overrides by analyzing your font files, and integrate with Vite/Webpack builds.

Building a Performance Dashboard

Once you’re collecting RUM data, you need somewhere to store and query it. For high-traffic pages, aggregate your vitals and set up alerting:

// api/vitals/route.ts
import { sql } from '@vercel/postgres';

export async function POST(request: Request) {
  const metric = await request.json();

  await sql`
    INSERT INTO web_vitals (name, value, rating, page, timestamp)
    VALUES (${metric.name}, ${metric.value}, ${metric.rating}, ${metric.page}, ${metric.timestamp})
  `;

  // Alert on regressions
  if (metric.rating === 'poor') {
    await sendAlert({
      metric: metric.name,
      value: metric.value,
      page: metric.page,
    });
  }

  return new Response('OK');
}

Query for p75 values (what Google uses for ranking):

-- PostgreSQL (MySQL/SQLite handle percentiles differently)
SELECT
  name,
  page,
  PERCENTILE_CONT(0.75) WITHIN GROUP (ORDER BY value) as p75,
  COUNT(*) as samples
FROM web_vitals
WHERE timestamp > NOW() - INTERVAL '24 hours'
GROUP BY name, page
ORDER BY p75 DESC;

The Performance Budget

Set concrete targets and enforce them:

// performance-budget.json
{
  "LCP": { "p75": 2500, "p95": 4000 },
  "INP": { "p75": 200, "p95": 500 },
  "CLS": { "p75": 0.1, "p95": 0.25 }
}

Fail CI if budgets are exceeded:

// scripts/check-vitals.ts
const budget = require('./performance-budget.json');

async function checkVitals() {
  const vitals = await fetchRecentVitals();

  for (const [metric, thresholds] of Object.entries(budget)) {
    const p75 = calculateP75(vitals[metric]);

    if (p75 > thresholds.p75) {
      console.error(`${metric} p75 (${p75}) exceeds budget (${thresholds.p75})`);
      process.exit(1);
    }
  }

  console.log('All vitals within budget');
}

Key Takeaways

  1. Lab scores lie - Your Lighthouse 100 means nothing if field data shows p75 LCP at 4 seconds. Set up RUM on day one.

  2. INP is harder than FID was - Budget 50ms per interaction handler, not 200ms total. Every long task is a potential INP regression.

  3. Attribution is non-negotiable - Without it, you’re guessing. The web-vitals attribution build adds ~2KB but saves hours of debugging.

  4. Reserve space or pay the CLS tax - Every ad slot, every lazy image, every async banner. If it loads later, reserve its height now.

  5. Budgets without enforcement are wishes - Wire vitals into CI. Fail builds that regress. On high-traffic pages, 100ms costs real money.