Core Web Vitals in CI: Measuring LCP, CLS, and INP Automatically on Every Build

Core Web Vitals in CI: Measuring LCP, CLS, and INP Automatically on Every Build

Core Web Vitals are Google's user-experience metrics that directly impact search rankings. Most teams check them manually in PageSpeed Insights—once a quarter, usually after someone notices traffic dropped. That's reactive and slow.

This guide shows you how to measure LCP, CLS, and INP automatically on every pull request, gate merges on regressions, and track trends over time.

What You're Actually Measuring

Google updated Core Web Vitals in March 2024. FID (First Input Delay) is out; INP (Interaction to Next Paint) is in. If your tooling still reports FID, it's measuring a deprecated metric.

Largest Contentful Paint (LCP) measures loading performance—when the largest visible element renders. Target: under 2.5s.

Cumulative Layout Shift (CLS) measures visual stability—how much elements jump around during load. Target: under 0.1.

Interaction to Next Paint (INP) measures interactivity—the worst interaction latency during the full page session. Target: under 200ms.

The catch: INP is hard to measure in CI because it requires real user interactions. LCP and CLS are automatable. INP in CI needs scripted interactions.

The Measurement Stack

Two approaches dominate CI performance testing:

Lighthouse-based (synthetic): Runs a scripted browser session in controlled conditions. Fully automatable, reproducible, but doesn't reflect real network/device diversity.

web-vitals.js + RUM (real user): Captures metrics from actual user sessions. Reflects reality, but not a CI gate—you can't block a deploy on RUM data that arrives hours later.

For CI gates, you want Lighthouse. For production monitoring, you want RUM. Run both.

Setting Up Lighthouse CI

Install the Lighthouse CI server and CLI:

npm install -g @lhci/cli
npm install --save-dev @lhci/cli

Create lighthouserc.js in your repo root:

module.exports = {
  ci: {
    collect: {
      url: ['http://localhost:3000/', 'http://localhost:3000/pricing'],
      numberOfRuns: 3,  // Average multiple runs for stability
      settings: {
        // Throttle to simulate mid-range mobile
        throttlingMethod: 'simulate',
        throttling: {
          rttMs: 40,
          throughputKbps: 10240,
          cpuSlowdownMultiplier: 4,
        },
        // Disable storage reset between runs for more realistic conditions
        disableStorageReset: false,
        // Use desktop form factor for web apps
        formFactor: 'desktop',
        screenEmulation: {
          mobile: false,
          width: 1350,
          height: 940,
          deviceScaleFactor: 1,
          disabled: false,
        },
      },
    },
    assert: {
      preset: 'lighthouse:no-pwa',
      assertions: {
        'first-contentful-paint': ['warn', { maxNumericValue: 1800 }],
        'largest-contentful-paint': ['error', { maxNumericValue: 2500 }],
        'cumulative-layout-shift': ['error', { maxNumericValue: 0.1 }],
        'total-blocking-time': ['warn', { maxNumericValue: 300 }],
        'interactive': ['warn', { maxNumericValue: 3800 }],
      },
    },
    upload: {
      target: 'temporary-public-storage',  // Free Lighthouse CI storage
    },
  },
};

Run it against your local server:

# Start your app in a separate terminal
npm run build && npm run start &

# Run LHCI
lhci autorun

GitHub Actions Integration

name: Lighthouse CI

on:
  pull_request:
    branches: [main]

jobs:
  lhci:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Build application
        run: npm run build
        env:
          NODE_ENV: production
      
      - name: Start server
        run: npm run start &
        env:
          PORT: 3000
      
      - name: Wait for server
        run: npx wait-on http://localhost:3000 --timeout 60000
      
      - name: Run Lighthouse CI
        run: lhci autorun
        env:
          LHCI_GITHUB_APP_TOKEN: ${{ secrets.LHCI_GITHUB_APP_TOKEN }}

The LHCI_GITHUB_APP_TOKEN enables Lighthouse CI to post results as a GitHub status check on the PR. Install the Lighthouse CI GitHub App to get the token.

Handling INP in CI

INP requires interactions. Lighthouse's TBT (Total Blocking Time) is the closest synthetic proxy—it correlates with INP because both reflect main-thread blocking.

For actual INP measurement in CI, use Playwright with the Web Vitals library:

// tests/web-vitals.spec.js
import { test, expect } from '@playwright/test';

test('homepage INP is under 200ms', async ({ page }) => {
  // Inject web-vitals.js before navigation
  await page.addInitScript(() => {
    window.__webVitals = {};
    
    // We'll inject the real web-vitals library
    const script = document.createElement('script');
    script.src = 'https://unpkg.com/web-vitals@3/dist/web-vitals.iife.js';
    script.onload = () => {
      webVitals.onINP((metric) => {
        window.__webVitals.inp = metric.value;
      });
    };
    document.head.appendChild(script);
  });
  
  await page.goto('/');
  
  // Simulate user interactions to trigger INP measurement
  await page.click('nav a:first-child');
  await page.click('button[data-testid="cta"]');
  await page.keyboard.press('Tab');
  await page.keyboard.press('Tab');
  
  // Wait for web-vitals to report
  await page.waitForTimeout(500);
  
  const inp = await page.evaluate(() => window.__webVitals.inp);
  
  if (inp !== undefined) {
    expect(inp).toBeLessThan(200);
  }
  // INP may be undefined if interactions didn't trigger it—that's okay
});

Measuring LCP Programmatically

For more control than Lighthouse provides, use the PerformanceObserver API directly in Playwright:

test('LCP is under 2500ms', async ({ page }) => {
  let lcp = 0;
  
  // Set up LCP collection before navigation
  await page.addInitScript(() => {
    window.__lcp = 0;
    
    const observer = new PerformanceObserver((entryList) => {
      const entries = entryList.getEntries();
      const lastEntry = entries[entries.length - 1];
      window.__lcp = lastEntry.startTime;
    });
    
    observer.observe({ type: 'largest-contentful-paint', buffered: true });
    
    // Finalize on page hide
    document.addEventListener('visibilitychange', () => {
      observer.takeRecords();
      observer.disconnect();
    });
  });
  
  const startTime = Date.now();
  await page.goto('/', { waitUntil: 'networkidle' });
  
  // Trigger LCP finalization
  await page.evaluate(() => {
    document.dispatchEvent(new Event('visibilitychange'));
  });
  
  lcp = await page.evaluate(() => window.__lcp);
  
  console.log(`LCP: ${lcp}ms`);
  expect(lcp).toBeLessThan(2500);
});

CLS: The Tricky One

CLS is cumulative—it measures layout shifts throughout the entire page lifecycle, not just at load. Measuring it correctly requires waiting for all shifts to occur:

test('CLS is under 0.1', async ({ page }) => {
  await page.addInitScript(() => {
    window.__cls = 0;
    
    const observer = new PerformanceObserver((entryList) => {
      for (const entry of entryList.getEntries()) {
        // Only count unexpected shifts (not caused by user interaction)
        if (!entry.hadRecentInput) {
          window.__cls += entry.value;
        }
      }
    });
    
    observer.observe({ type: 'layout-shift', buffered: true });
  });
  
  await page.goto('/');
  
  // Scroll through the page to trigger lazy-loaded content
  await page.evaluate(() => {
    return new Promise((resolve) => {
      let totalHeight = 0;
      const distance = 100;
      const timer = setInterval(() => {
        window.scrollBy(0, distance);
        totalHeight += distance;
        if (totalHeight >= document.documentElement.scrollHeight) {
          clearInterval(timer);
          resolve();
        }
      }, 100);
    });
  });
  
  // Wait for any deferred images or fonts
  await page.waitForTimeout(1000);
  
  const cls = await page.evaluate(() => window.__cls);
  console.log(`CLS: ${cls}`);
  expect(cls).toBeLessThan(0.1);
});

Common CLS culprits: images without explicit dimensions, ads injected after load, web fonts causing FOUT, embeds with dynamic height.

Stabilizing Results

Performance measurements are noisy. Three runs and an average is the minimum:

// lighthouse-runner.js
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);

async function runLighthouseMultiple(url, runs = 3) {
  const results = [];
  
  for (let i = 0; i < runs; i++) {
    const { stdout } = await execAsync(
      `lighthouse ${url} --output json --quiet --chrome-flags="--headless"`
    );
    const report = JSON.parse(stdout);
    results.push({
      lcp: report.audits['largest-contentful-paint'].numericValue,
      cls: report.audits['cumulative-layout-shift'].numericValue,
      tbt: report.audits['total-blocking-time'].numericValue,
    });
  }
  
  // Return median values
  return {
    lcp: median(results.map(r => r.lcp)),
    cls: median(results.map(r => r.cls)),
    tbt: median(results.map(r => r.tbt)),
  };
}

function median(values) {
  const sorted = [...values].sort((a, b) => a - b);
  const mid = Math.floor(sorted.length / 2);
  return sorted.length % 2 !== 0
    ? sorted[mid]
    : (sorted[mid - 1] + sorted[mid]) / 2;
}

Also: run on consistent hardware. GitHub's ubuntu-latest runners vary in CPU speed. Pin to a specific runner type if you need reproducibility, or accept 10-15% variance in results.

Point-in-time measurements miss gradual regressions. A page that was 2.3s LCP six months ago and is now 2.4s isn't failing any threshold—but it's degrading.

Use LHCI's server to store historical results:

# Run LHCI server locally or on a VPS
npx @lhci/server start --storage.storageMethod=sql \
  --storage.sqlDialect=sqlite \
  --storage.sqlDatabasePath=./lhci.db

Or use the hosted LHCI server. Configure your lighthouserc.js to upload:

upload: {
  target: 'lhci',
  serverBaseUrl: 'https://your-lhci-server.com',
  token: process.env.LHCI_TOKEN,
},

This gives you trend graphs showing how metrics change across builds—useful for catching gradual regressions before they cross your budget thresholds.

What Good CI Coverage Looks Like

Run Lighthouse on:

  • Your landing page (highest traffic, SEO impact)
  • One authenticated user flow (different asset set, different LCP element)
  • Any page with complex widgets (likely CLS and TBT issues)

Don't run it on every page—it's slow and noisy. Focus on the pages that matter for SEO and conversion.

Set thresholds tighter than Google's "Good" range in your CI budgets. If your threshold is 2.5s and your current LCP is 2.4s, one slow image CDN day will break your build. Set the CI threshold to 80% of the "Good" ceiling: 2.0s for LCP, 0.08 for CLS.

Connecting to HelpMeTest

HelpMeTest's Playwright-based testing integrates directly with this approach. Write your Web Vitals checks as regular test scenarios—LCP assertions, CLS measurements, INP interaction scripts—and run them as part of your 24/7 monitoring, not just in CI.

This means you catch regressions that only appear under real traffic conditions (CDN cache misses, database query spikes, third-party script failures) that your synthetic CI tests can't simulate.


Core Web Vitals in CI is a solved problem. The tooling exists, the GitHub Actions integration is straightforward, and the payoff—catching performance regressions before they hit production—is high. The main failure mode is treating it as optional. Make it a required status check.

Read more

Start now free