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/cliCreate 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 autorunGitHub 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.
Tracking Trends Over Time
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.dbOr 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.