Core Web Vitals Testing: Measure LCP, CLS, and INP in Your Pipeline

Core Web Vitals Testing: Measure LCP, CLS, and INP in Your Pipeline

Core Web Vitals are Google's three user-experience metrics that directly affect search ranking: Largest Contentful Paint (LCP), Cumulative Layout Shift (CLS), and Interaction to Next Paint (INP). This guide covers how to measure them accurately, set up regression testing in CI, and correlate lab data with field data.

The Three Core Web Vitals

Metric What it measures Good Needs Improvement Poor
LCP Loading — when the largest visible element renders ≤ 2.5s 2.5–4.0s > 4.0s
CLS Visual stability — total layout shift score ≤ 0.1 0.1–0.25 > 0.25
INP Interactivity — worst interaction latency (p98) ≤ 200ms 200–500ms > 500ms

INP replaced First Input Delay (FID) as a Core Web Vital in March 2024. FID measured only the first interaction's input delay; INP measures all interactions throughout the session.

Measuring in the Browser with the web-vitals Library

The web-vitals library (by Google) is the canonical way to collect field data:

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

// These callbacks fire when the value is finalized
onLCP(({ value, rating }) => {
  console.log(`LCP: ${value}ms — ${rating}`); // rating: 'good' | 'needs-improvement' | 'poor'
  sendToAnalytics({ metric: 'LCP', value, rating });
});

onCLS(({ value, rating }) => {
  console.log(`CLS: ${value}${rating}`);
  sendToAnalytics({ metric: 'CLS', value, rating });
});

onINP(({ value, rating, entries }) => {
  console.log(`INP: ${value}ms — ${rating}`);
  // entries contains the specific interactions that contributed
  sendToAnalytics({ metric: 'INP', value, rating });
});

function sendToAnalytics(data) {
  // Use navigator.sendBeacon for reliable delivery on page unload
  navigator.sendBeacon('/analytics', JSON.stringify(data));
}

The callbacks fire at different times:

  • LCP: fires when LCP is determined (after user interaction or page hide)
  • CLS: fires on page hide with the final accumulated score
  • INP: fires on page hide with the worst interaction from the session

Lab Testing with Playwright

Lab data is reproducible and works in CI. Use Playwright with Chrome's DevTools Protocol:

const { chromium } = require('playwright');

async function measureCoreWebVitals(url) {
  const browser = await chromium.launch();
  const context = await browser.newContext();
  const page = await context.newPage();

  // Inject web-vitals before navigation
  await page.addInitScript(() => {
    window.__webVitals = {};
  });

  // Use CDP to capture paint timing
  const client = await page.context().newCDPSession(page);
  await client.send('Performance.enable');

  await page.goto(url, { waitUntil: 'networkidle' });

  // Get performance metrics via CDP
  const metrics = await client.send('Performance.getMetrics');
  const metricsMap = Object.fromEntries(
    metrics.metrics.map(m => [m.name, m.value])
  );

  // LCP via PerformanceObserver
  const lcp = await page.evaluate(() => {
    return new Promise((resolve) => {
      const observer = new PerformanceObserver((list) => {
        const entries = list.getEntries();
        resolve(entries[entries.length - 1].startTime);
      });
      observer.observe({ type: 'largest-contentful-paint', buffered: true });
      setTimeout(() => resolve(null), 5000);
    });
  });

  // CLS via PerformanceObserver
  const cls = await page.evaluate(() => {
    return new Promise((resolve) => {
      let clsValue = 0;
      const observer = new PerformanceObserver((list) => {
        for (const entry of list.getEntries()) {
          if (!entry.hadRecentInput) {
            clsValue += entry.value;
          }
        }
      });
      observer.observe({ type: 'layout-shift', buffered: true });
      setTimeout(() => resolve(clsValue), 3000);
    });
  });

  await browser.close();
  return { lcp, cls };
}

// Test with assertions
const vitals = await measureCoreWebVitals('https://example.com');
console.assert(vitals.lcp < 2500, `LCP too slow: ${vitals.lcp}ms`);
console.assert(vitals.cls < 0.1, `CLS too high: ${vitals.cls}`);

Testing INP

INP is harder to test in lab conditions because it requires real interactions. Use Playwright to simulate user interactions and measure response time:

async function measureINP(page) {
  const interactions = [];

  // Listen for interaction timing
  await page.exposeFunction('recordInteraction', (data) => {
    interactions.push(data);
  });

  await page.addInitScript(() => {
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        if (entry.interactionId) {
          window.recordInteraction({
            duration: entry.duration,
            processingStart: entry.processingStart,
            processingEnd: entry.processingEnd,
            type: entry.name,
          });
        }
      }
    });
    observer.observe({ type: 'event', buffered: true, durationThreshold: 0 });
  });

  await page.goto('https://example.com');

  // Simulate typical user interactions
  await page.click('.add-to-cart');
  await page.waitForTimeout(100);
  await page.click('.open-menu');
  await page.waitForTimeout(100);
  await page.type('#search', 'test query');
  await page.waitForTimeout(100);

  // INP is the 98th percentile — approximate with max for small sample
  const maxDuration = Math.max(...interactions.map(i => i.duration));
  return maxDuration;
}

For more realistic INP testing, use web-vitals with reportAllChanges: true:

import { onINP } from 'web-vitals';

onINP(({ value, entries }) => {
  // Log the worst interaction
  const worst = entries.sort((a, b) => b.duration - a.duration)[0];
  console.log(`INP: ${value}ms, worst interaction: ${worst.name}`);
}, { reportAllChanges: true });

Lighthouse CI for Core Web Vitals

Lighthouse CI (lhci) provides automated Core Web Vitals tracking per-commit:

npm install -g @lhci/cli

.lighthouserc.json with CWV-specific assertions:

{
  "ci": {
    "collect": {
      "url": [
        "http://localhost:3000",
        "http://localhost:3000/products/featured",
        "http://localhost:3000/checkout"
      ],
      "numberOfRuns": 5,
      "settings": {
        "preset": "desktop"
      }
    },
    "assert": {
      "assertions": {
        "largest-contentful-paint": ["error", {"maxNumericValue": 2500}],
        "cumulative-layout-shift": ["error", {"maxNumericValue": 0.1}],
        "total-blocking-time": ["warn", {"maxNumericValue": 200}]
      }
    },
    "upload": {
      "target": "lhci",
      "serverBaseUrl": "https://your-lhci-server.example.com",
      "token": "$LHCI_TOKEN"
    }
  }
}

GitHub Actions integration:

name: Core Web Vitals
on: [push, pull_request]

jobs:
  cwv:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npm run build
      - name: Start server
        run: npm start &
        env:
          PORT: 3000
      - name: Wait for server
        run: npx wait-on http://localhost:3000
      - name: Run Lighthouse CI
        run: npx lhci autorun
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}

Field Data vs Lab Data

Lab data (Lighthouse, Playwright) is reproducible but artificial. Field data from real users often looks very different:

Scenario Lab Field
Network speed Throttled 4G (simulated) Actual user connections
CPU speed 4× slowdown Actual devices
Location Single runner location Global user distribution
Cache state Cold cache Warm cache for returning users
Third-party scripts All loaded Ad blockers may block some

The gap is often large. A page with a 2.1s lab LCP might have a 3.8s field LCP for mobile users on slower connections.

To bridge the gap:

  1. Use Chrome UX Report (CrUX) for free field data on any URL with enough traffic
  2. Implement web-vitals in your app and send to your analytics
  3. Use Real User Monitoring (RUM) tools
// Query CrUX API
const response = await fetch('https://chromeuxreport.googleapis.com/v1/records:queryRecord', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    url: 'https://example.com/',
    metrics: ['largest_contentful_paint', 'cumulative_layout_shift', 'interaction_to_next_paint'],
    key: YOUR_API_KEY,
  }),
});

const data = await response.json();
const lcpP75 = data.record.metrics.largest_contentful_paint.percentiles.p75;
console.log(`Field LCP p75: ${lcpP75}ms`);

Diagnosing LCP Specifically

LCP is the most actionable CWV. When it's slow, determine which sub-part is responsible:

// Break down LCP into its components
const observer = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const lastEntry = entries[entries.length - 1];
  
  // LCP sub-parts (Chrome 77+)
  const resourceLoadDelay = lastEntry.loadStart - lastEntry.startTime;
  const resourceLoadTime = lastEntry.loadEnd - lastEntry.loadStart;
  const elementRenderDelay = lastEntry.startTime - lastEntry.loadEnd;
  
  console.log({
    'TTFB (affects all)': performance.timing.responseStart - performance.timing.requestStart,
    'Resource load delay': resourceLoadDelay,
    'Resource load time': resourceLoadTime,
    'Element render delay': elementRenderDelay,
    'LCP': lastEntry.startTime,
  });
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });

The breakdown tells you where to focus:

  • High TTFB → server/CDN problem
  • High resource load delay → LCP element is discovered late (not in HTML, loaded by JS)
  • High resource load time → large file, slow CDN, no compression
  • High element render delay → render-blocking resources, main thread blocking

Setting Up a Regression Budget

Track Core Web Vitals per page as a time series. Set budgets and alert on regressions:

// .cwv-budget.json
{
  "pages": {
    "/": { "lcp": 2000, "cls": 0.05, "inp": 150 },
    "/products": { "lcp": 2500, "cls": 0.1, "inp": 200 },
    "/checkout": { "lcp": 1800, "cls": 0.05, "inp": 100 }
  },
  "alert_threshold": 0.1  // fail if metric exceeds budget by >10%
}

Use this budget in CI to fail builds when specific pages regress:

const budget = require('./.cwv-budget.json');
const results = require('./lhci-results.json');

for (const [page, limits] of Object.entries(budget.pages)) {
  const pageResult = results.find(r => r.url.endsWith(page));
  if (!pageResult) continue;
  
  const lcp = pageResult.audits['largest-contentful-paint'].numericValue;
  const threshold = limits.lcp * (1 + budget.alert_threshold);
  
  if (lcp > threshold) {
    console.error(`❌ LCP regression on ${page}: ${lcp}ms > ${threshold}ms budget`);
    process.exit(1);
  }
}

Core Web Vitals testing is most effective as a two-layer system: lab tests in CI to catch regressions before deploy, and field data monitoring to catch what lab tests miss in production.

Read more

Start now free