TechLead
Lesson 2 of 22
6 min read
Performance Engineering

Core Web Vitals Deep Dive

Master LCP, INP, and CLS — the metrics Google uses for search ranking and user experience assessment

What Are Core Web Vitals?

Core Web Vitals are a set of three specific metrics that Google considers essential for delivering a great user experience on the web. They measure loading performance, interactivity, and visual stability. Since June 2021, they are a direct Google search ranking factor, making them critical for both user experience and SEO.

The Three Core Web Vitals (2024+)

  • LCP (Largest Contentful Paint): Measures loading — when the largest content element becomes visible. Target: under 2.5 seconds
  • INP (Interaction to Next Paint): Measures responsiveness — the latency of all interactions throughout the page lifecycle. Target: under 200 milliseconds
  • CLS (Cumulative Layout Shift): Measures visual stability — the sum of unexpected layout shifts. Target: under 0.1

Largest Contentful Paint (LCP)

LCP measures when the largest content element in the viewport becomes visible. This is typically a hero image, a large block of text, or a video thumbnail. LCP is the most important loading metric because it reflects when the user perceives the main content has loaded.

// Monitoring LCP with detailed attribution
function observeLCP(): void {
  const observer = new PerformanceObserver((list) => {
    const entries = list.getEntries();
    const lastEntry = entries[entries.length - 1] as any;

    console.log('LCP Details:', {
      value: lastEntry.startTime,
      element: lastEntry.element?.tagName,
      url: lastEntry.url,
      size: lastEntry.size,
      loadTime: lastEntry.loadTime,
      renderTime: lastEntry.renderTime,
    });

    // Breakdown of LCP sub-parts
    const navEntry = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
    const ttfb = navEntry.responseStart - navEntry.requestStart;
    const resourceLoadDelay = lastEntry.startTime - ttfb;

    console.log('LCP Breakdown:', {
      ttfb: `${ttfb.toFixed(0)}ms`,
      resourceLoadDelay: `${resourceLoadDelay.toFixed(0)}ms`,
      totalLCP: `${lastEntry.startTime.toFixed(0)}ms`,
    });
  });

  observer.observe({ type: 'largest-contentful-paint', buffered: true });
}

// Common LCP optimizations in Next.js
import Image from 'next/image';

// GOOD: Priority loading for LCP image
function HeroSection() {
  return (
    <Image
      src="/hero-banner.webp"
      alt="Hero"
      width={1200}
      height={600}
      priority  // Adds preload link, disables lazy loading
      sizes="100vw"
    />
  );
}

LCP Optimization Checklist

  • Reduce TTFB: Use a CDN, optimize server response, enable caching
  • Eliminate render-blocking resources: Inline critical CSS, defer non-critical JS
  • Preload the LCP resource: Use <link rel="preload"> for hero images or fonts
  • Optimize images: Use WebP/AVIF, responsive sizes, proper compression
  • Use priority hints: fetchpriority="high" on LCP images
  • Avoid client-side rendering: SSR or SSG the LCP content

Interaction to Next Paint (INP)

INP replaced First Input Delay (FID) as a Core Web Vital in March 2024. While FID only measured the first interaction, INP captures the responsiveness of all interactions throughout the entire page lifecycle. INP is the worst-case interaction latency (approximately the 98th percentile).

// Measuring INP with the web-vitals library
import { onINP } from 'web-vitals';

onINP((metric) => {
  console.log('INP:', {
    value: metric.value,
    rating: metric.rating, // 'good', 'needs-improvement', 'poor'
    entries: metric.entries.map((entry) => ({
      name: entry.name,
      duration: entry.duration,
      processingStart: entry.processingStart,
      processingEnd: entry.processingEnd,
      startTime: entry.startTime,
      // Break down the interaction
      inputDelay: entry.processingStart - entry.startTime,
      processingTime: entry.processingEnd - entry.processingStart,
      presentationDelay: entry.duration - (entry.processingEnd - entry.startTime),
    })),
  });
});

// Optimizing event handlers for better INP
function OptimizedList({ items }: { items: string[] }) {
  const handleClick = async (id: string) => {
    // 1. Provide immediate visual feedback
    const element = document.getElementById(id);
    element?.classList.add('active');

    // 2. Yield to let the browser paint the feedback
    await new Promise(resolve => setTimeout(resolve, 0));

    // 3. Do the expensive work after paint
    await processExpensiveOperation(id);
  };

  return (
    <ul>
      {items.map(item => (
        <li key={item} id={item} onClick={() => handleClick(item)}>
          {item}
        </li>
      ))}
    </ul>
  );
}

async function processExpensiveOperation(id: string): Promise<void> {
  // Use requestIdleCallback or scheduler.postTask for non-urgent work
  if ('scheduler' in globalThis) {
    await (globalThis as any).scheduler.postTask(
      () => { /* expensive computation */ },
      { priority: 'background' }
    );
  }
}

Cumulative Layout Shift (CLS)

CLS measures the visual stability of a page by quantifying how much visible content shifts unexpectedly. Each layout shift is scored based on two factors: the impact fraction (how much of the viewport is affected) and the distance fraction (how far elements move). CLS is the sum of all unexpected shift scores.

// Debugging CLS issues
function debugCLS(): void {
  const observer = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      const shift = entry as any;
      if (!shift.hadRecentInput) {
        console.log('Layout shift detected:', {
          value: shift.value,
          sources: shift.sources?.map((source: any) => ({
            node: source.node?.nodeName,
            id: source.node?.id,
            className: source.node?.className,
            previousRect: source.previousRect,
            currentRect: source.currentRect,
          })),
        });
      }
    }
  });
  observer.observe({ type: 'layout-shift', buffered: true });
}

// Preventing CLS with proper dimension reservations
// In Next.js, always specify width and height for images
import Image from 'next/image';

function StableLayout() {
  return (
    <div>
      {/* GOOD: Dimensions reserved, no layout shift */}
      <Image src="/photo.jpg" alt="Photo" width={800} height={600} />

      {/* GOOD: Aspect ratio container for dynamic content */}
      <div style={{ aspectRatio: '16/9', position: 'relative' }}>
        <Image src="/video-thumb.jpg" alt="Video" fill sizes="100vw" />
      </div>

      {/* GOOD: min-height for dynamic content areas */}
      <div style={{ minHeight: '200px' }}>
        {/* Content loaded asynchronously */}
      </div>
    </div>
  );
}

Common CLS Causes and Fixes

  • Images without dimensions: Always specify width/height or use aspect-ratio
  • Ads and embeds: Reserve space with min-height containers
  • Web fonts (FOUT/FOIT): Use font-display: swap with size-adjust or preload fonts
  • Dynamic content insertion: Insert content below the fold or use CSS containment
  • Animations: Use transform instead of top/left/width/height changes

Measuring Core Web Vitals in Production

// Complete RUM (Real User Monitoring) setup with web-vitals
import { onLCP, onINP, onCLS, onFCP, onTTFB, type Metric } from 'web-vitals';

interface VitalMetric {
  name: string;
  value: number;
  rating: string;
  navigationType: string;
  url: string;
}

function sendToAnalytics(metric: Metric): void {
  const body: VitalMetric = {
    name: metric.name,
    value: metric.value,
    rating: metric.rating,
    navigationType: metric.navigationType,
    url: window.location.href,
  };

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

// Initialize all observers
onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);
onFCP(sendToAnalytics);
onTTFB(sendToAnalytics);

// Next.js API route to receive vitals
// app/api/vitals/route.ts
import { NextRequest, NextResponse } from 'next/server';

export async function POST(request: NextRequest) {
  const vital = await request.json();

  // Store in your database or analytics service
  console.log('Web Vital received:', vital);

  // Forward to your analytics pipeline
  // await db.insert('web_vitals', vital);

  return NextResponse.json({ status: 'ok' });
}

Key Takeaways

  • LCP under 2.5s: Optimize server response, preload critical resources, use efficient image formats
  • INP under 200ms: Keep event handlers fast, yield to the main thread, minimize JavaScript
  • CLS under 0.1: Reserve space for dynamic content, avoid inserting content above the fold
  • Measure in the field: Lab tools cannot replicate real-world network and device diversity

Continue Learning