Skip to main content
Technology & EngineeringSeo Content262 lines

Core Web Vitals

Core Web Vitals optimization: LCP, INP, and CLS measurement, diagnosis, and improvement strategies for better search rankings and user experience.

Quick Summary15 lines
Core Web Vitals are not abstract performance metrics -- they are direct measurements of user experience that Google uses as ranking signals. A page that loads its largest element in under 2.5 seconds, responds to interactions in under 200 milliseconds, and maintains visual stability during load is a page that respects the user's time and attention. Optimizing for these metrics is simultaneously optimizing for user satisfaction and search visibility.

## Key Points

- Set `fetchpriority="high"` on the LCP element and `loading="lazy"` on all below-the-fold images. Never lazy-load the LCP image.
- Use `content-visibility: auto` on long pages to skip rendering of off-screen sections, improving both LCP and INP by reducing initial layout and paint work.
- Lazy-loading the LCP image. This is the single most common LCP regression. The LCP image must load eagerly with high fetch priority.

## Quick Example

```html
<link rel="preload" href="/fonts/custom.woff2" as="font" type="font/woff2" crossorigin />
```
skilldb get seo-content-skills/Core Web VitalsFull skill: 262 lines
Paste into your CLAUDE.md or agent config

Core Web Vitals — SEO & Content

Core Philosophy

Core Web Vitals are not abstract performance metrics -- they are direct measurements of user experience that Google uses as ranking signals. A page that loads its largest element in under 2.5 seconds, responds to interactions in under 200 milliseconds, and maintains visual stability during load is a page that respects the user's time and attention. Optimizing for these metrics is simultaneously optimizing for user satisfaction and search visibility.

Field data from real users always takes precedence over lab data from Lighthouse. Google uses field data (CrUX) for ranking decisions, and lab results frequently diverge from real-world performance due to differences in device capabilities, network conditions, and user behavior patterns. Measure what real users experience through the web-vitals library or CrUX, and treat Lighthouse as a diagnostic tool rather than the source of truth.

The most common performance regressions are caused by well-intentioned features: lazy-loading the LCP image, inserting dynamic content that shifts layout, and loading heavy JavaScript that blocks interaction responsiveness. Understanding what each metric actually measures -- and what browser behaviors trigger regressions -- prevents introducing problems while trying to solve them.

You are an expert in Core Web Vitals optimization, covering Largest Contentful Paint (LCP), Interaction to Next Paint (INP), and Cumulative Layout Shift (CLS) measurement, diagnosis, and remediation.

Overview

Core Web Vitals are Google's set of real-world performance metrics that directly influence search rankings. They measure loading speed (LCP), interactivity responsiveness (INP), and visual stability (CLS). Since 2021, these metrics have been a confirmed ranking signal. Meeting the "good" thresholds improves both SEO positioning and user experience, reducing bounce rates and increasing conversions.

Core Concepts

The Three Metrics

MetricMeasuresGoodNeeds ImprovementPoor
LCP (Largest Contentful Paint)Time until the largest visible element renders<= 2.5s<= 4.0s> 4.0s
INP (Interaction to Next Paint)Latency from user input to visual update<= 200ms<= 500ms> 500ms
CLS (Cumulative Layout Shift)Total unexpected layout movement<= 0.1<= 0.25> 0.25

LCP — What Counts as the Largest Element

LCP candidates include <img>, <video> poster images, elements with background-image via CSS, and block-level text elements (<h1>, <p>, etc.). The browser re-evaluates the LCP candidate as content loads — the final candidate when the user first interacts or the page fully loads is the reported LCP.

INP — Replacing FID

INP replaced First Input Delay (FID) in March 2024. Unlike FID which only measured the first interaction's input delay, INP measures the full latency of all interactions throughout the page lifecycle and reports the worst (or near-worst at the 98th percentile). This means long-running JavaScript that blocks any interaction — not just the first — now affects the score.

CLS — Session Windows

CLS groups layout shifts into "session windows" — bursts of shifts within 1 second, with a maximum 5-second window. The largest single session window score is the reported CLS value. Shifts caused by user interaction (clicking a button that expands content) are excluded.

Implementation Patterns

Measuring Core Web Vitals in Code

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

function sendToAnalytics(metric: Metric) {
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
    rating: metric.rating, // "good" | "needs-improvement" | "poor"
    delta: metric.delta,
    id: metric.id,
    navigationType: metric.navigationType,
    entries: metric.entries.map((e) => ({
      name: (e as PerformanceEntry).name,
      startTime: e.startTime,
    })),
  });

  // 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 reportWebVitals() {
  onLCP(sendToAnalytics);
  onINP(sendToAnalytics);
  onCLS(sendToAnalytics);
}

LCP Optimization — Image Preloading

<!-- Preload the LCP image in <head> -->
<link
  rel="preload"
  as="image"
  href="/hero-image.webp"
  fetchpriority="high"
  type="image/webp"
/>
// Next.js: Use priority prop on the LCP image
import Image from "next/image";

export function HeroBanner() {
  return (
    <Image
      src="/hero-image.webp"
      alt="Hero banner"
      width={1200}
      height={600}
      priority // disables lazy loading, adds fetchpriority="high"
      sizes="100vw"
    />
  );
}

LCP Optimization — Font Loading

/* Prevent font-swap layout shifts and render blocking */
@font-face {
  font-family: "CustomFont";
  src: url("/fonts/custom.woff2") format("woff2");
  font-display: swap; /* Show fallback immediately, swap when loaded */
  unicode-range: U+0000-00FF; /* Only load needed character ranges */
}
<link rel="preload" href="/fonts/custom.woff2" as="font" type="font/woff2" crossorigin />

INP Optimization — Breaking Up Long Tasks

// Bad: Blocks the main thread for the entire duration
function processLargeDataset(items: DataItem[]) {
  items.forEach((item) => expensiveOperation(item)); // 200ms+ blocking
  updateUI();
}

// Good: Yield to the browser between chunks
async function processLargeDataset(items: DataItem[]) {
  const chunkSize = 50;
  for (let i = 0; i < items.length; i += chunkSize) {
    const chunk = items.slice(i, i + chunkSize);
    chunk.forEach((item) => expensiveOperation(item));

    // Yield to let the browser process pending interactions
    await new Promise((resolve) => setTimeout(resolve, 0));
  }
  updateUI();
}

// Best: Use scheduler.yield() when available
async function processWithYield(items: DataItem[]) {
  for (const item of items) {
    expensiveOperation(item);
    if ("scheduler" in globalThis && "yield" in scheduler) {
      await scheduler.yield();
    }
  }
  updateUI();
}

CLS Optimization — Reserving Space

/* Reserve space for images to prevent layout shift */
.image-container {
  aspect-ratio: 16 / 9;
  width: 100%;
  background-color: #f0f0f0; /* placeholder color */
}

/* Reserve space for ads or dynamic embeds */
.ad-slot {
  min-height: 250px; /* match expected ad height */
  contain: layout; /* prevent internal changes from shifting surroundings */
}

/* Prevent font-swap CLS */
.text-content {
  font-synthesis: none;
}

CLS Optimization — Dynamic Content Insertion

// Bad: Inserting a banner pushes content down
function showBanner() {
  const banner = document.createElement("div");
  banner.textContent = "Sale!";
  document.body.prepend(banner); // Shifts everything below
}

// Good: Use CSS containment and reserved space
function showBanner() {
  const slot = document.getElementById("banner-slot"); // pre-reserved space
  if (slot) {
    slot.textContent = "Sale!";
    slot.style.visibility = "visible"; // was hidden, not display:none
  }
}

Automated Monitoring with Lighthouse CI

# .github/workflows/lighthouse.yml
name: Lighthouse CI
on: [pull_request]
jobs:
  lighthouse:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci && npm run build
      - name: Run Lighthouse
        uses: treosh/lighthouse-ci-action@v12
        with:
          urls: |
            http://localhost:3000/
            http://localhost:3000/blog
          budgetPath: ./lighthouse-budget.json
          uploadArtifacts: true
// lighthouse-budget.json
[
  {
    "path": "/*",
    "timings": [
      { "metric": "largest-contentful-paint", "budget": 2500 },
      { "metric": "cumulative-layout-shift", "budget": 0.1 },
      { "metric": "interactive", "budget": 3500 }
    ]
  }
]

Best Practices

  • Measure field data (real users via CrUX or web-vitals library), not just lab data (Lighthouse). Google uses field data for ranking decisions, and lab results often differ significantly from real-world performance.
  • Set fetchpriority="high" on the LCP element and loading="lazy" on all below-the-fold images. Never lazy-load the LCP image.
  • Use content-visibility: auto on long pages to skip rendering of off-screen sections, improving both LCP and INP by reducing initial layout and paint work.

Anti-Patterns

  • Lazy-loading the LCP image: The single most common LCP regression. The largest contentful element must load eagerly with fetchpriority="high". Lazy-loading it delays the metric that matters most.
  • Using display: none for dynamic content slots: When the element later becomes visible, it causes layout shift. Use visibility: hidden with pre-allocated dimensions or CSS contain to isolate the shift.
  • Blocking the main thread with synchronous JavaScript: Long tasks that run during page load or interaction delay both LCP and INP. Break long tasks into chunks that yield to the browser between operations.
  • Ignoring font-loading CLS: Custom fonts that load asynchronously cause text reflow when they swap in, contributing to CLS. Use font-display: swap combined with preloading and matched fallback font metrics.
  • Optimizing lab scores instead of field data: Achieving a Lighthouse 100 that does not reflect real user experience. Lab conditions differ from production; always validate optimizations against CrUX field data.

Common Pitfalls

  • Lazy-loading the LCP image. This is the single most common LCP regression. The LCP image must load eagerly with high fetch priority.
  • Using display: none to hide placeholder elements for dynamic content. When the element later becomes display: block, it causes layout shift. Use visibility: hidden with pre-allocated dimensions instead, or use CSS contain to isolate the shift.

Install this skill directly: skilldb add seo-content-skills

Get CLI access →