Skip to main content

Fix CWV Regressions: Alerts, Budgets & Debugging

A Core Web Vitals regression is a sudden rise in LCP, INP, or CLS that fails the "good" threshold and hurts your search ranking. Regressions happen after deployments (new code), third-party script additions (analytics, ads), or gradual traffic changes (more slow-network users). Catching and fixing regressions requires three strategies: performance budgets to prevent large regressions from being deployed, real-time alerting to notify the team when field data degrades, and systematic debugging to identify the root cause (code, assets, or third-party).

Setting Performance Budgets

A performance budget is a hard limit on key metrics. If a new code change exceeds the budget, the CI/CD pipeline blocks the deployment. Budgets are typically 5–10% more strict than "good" to leave room for normal variation.

Example budgets for a React e-commerce site:

MetricGood ThresholdBudgetTolerance
LCP≤ 2.5s≤ 2.2s12% buffer
INP≤ 200ms≤ 180ms10% buffer
CLS≤ 0.1≤ 0.0820% buffer

Lighthouse CI for Budgets

Lighthouse CI (LHCI) runs Lighthouse in your CI/CD pipeline and blocks merges if budgets are exceeded:

npm install --save-dev @lhci/cli

Configuration file (.lighthouserc.json):

{
"ci": {
"collect": {
"url": ["https://staging.example.com/"],
"numberOfRuns": 3,
"settings": {
"configPath": "./lighthouse-config.js"
}
},
"upload": {
"target": "temporary-public-storage"
},
"assert": {
"preset": "lighthouse:recommended",
"assertions": {
"categories:performance": ["error", { "minScore": 0.85 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2200 }],
"interaction-to-next-paint": ["error", { "maxNumericValue": 180 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.08 }],
"first-contentful-paint": ["warn", { "maxNumericValue": 1800 }]
}
}
}
}

Add to CI/CD (GitHub Actions example):

# .github/workflows/lighthouse-ci.yml
name: Lighthouse CI

on:
pull_request:
branches: [main]

jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'

- run: npm install
- run: npm run build
- run: npm run start &

- uses: treosh/lighthouse-ci-action@v9
with:
configPath: './.lighthouserc.json'
uploadArtifacts: true

Now every PR is tested against performance budgets. If a PR raises LCP from 1.8s to 2.4s, the CI fails and the developer must fix it before merging.

Real-Time Alerting on Field Data

Monitor CrUX field data and alert when P75 metrics degrade. Use a cloud function or cron job to check CrUX daily:

// scripts/check-crux-regression.js
import fetch from 'node-fetch';
import { SlackNotifier } from './slack-notifier';

const DOMAIN = 'example.com';
const SLACK_WEBHOOK = process.env.SLACK_WEBHOOK_URL;
const PREVIOUS_RESULTS_FILE = './crux-baseline.json';

async function getCrUXData() {
const response = await fetch('https://www.googleapis.com/chromeuxreport/v1/records:queryRecord', {
method: 'POST',
headers: { 'X-Goog-Api-Key': process.env.GOOGLE_API_KEY },
body: JSON.stringify({ origin: `https://${DOMAIN}` }),
});

return response.json();
}

function parseMetrics(cruxData) {
const record = cruxData.record;
return {
lcp: record.metrics.largest_contentful_paint.percentiles[20], // p75 is at index 20 of 21
inp: record.metrics.interaction_to_next_paint.percentiles[20],
cls: record.metrics.cumulative_layout_shift.percentiles[20],
};
}

async function checkForRegressions() {
const current = parseMetrics(await getCrUXData());
const previous = JSON.parse(fs.readFileSync(PREVIOUS_RESULTS_FILE, 'utf8'));

const regressions = [];

// LCP threshold: 2500 ms
if (current.lcp > 2500 && current.lcp > previous.lcp * 1.1) {
regressions.push(`LCP regressed: ${previous.lcp}ms → ${current.lcp}ms (+${Math.round((current.lcp - previous.lcp) / previous.lcp * 100)}%)`);
}

// INP threshold: 200 ms
if (current.inp > 200 && current.inp > previous.inp * 1.1) {
regressions.push(`INP regressed: ${previous.inp}ms → ${current.inp}ms (+${Math.round((current.inp - previous.inp) / previous.inp * 100)}%)`);
}

// CLS threshold: 0.1
if (current.cls > 0.1 && current.cls > previous.cls * 1.1) {
regressions.push(`CLS regressed: ${previous.cls}${current.cls} (+${Math.round((current.cls - previous.cls) / previous.cls * 100)}%)`);
}

if (regressions.length > 0) {
await SlackNotifier.alert(`Core Web Vitals Regression Detected:\n${regressions.join('\n')}\nInvestigate: https://crux.run?url=${DOMAIN}`);
}

// Update baseline for next check
fs.writeFileSync(PREVIOUS_RESULTS_FILE, JSON.stringify(current, null, 2));
}

checkForRegressions().catch(console.error);

Schedule with a cloud function (e.g., Google Cloud Scheduler, AWS Lambda) to run daily.

Alternatively, use your RUM data to detect regressions faster (field data is updated in real-time):

// scripts/monitor-rum-data.js (runs continuously)
const RUM_ENDPOINT = process.env.RUM_ENDPOINT;
const SLACK_WEBHOOK = process.env.SLACK_WEBHOOK_URL;

async function monitorRUMRegressions() {
// Query RUM database for last 1 hour
const regressions = await fetch(`${RUM_ENDPOINT}/api/regressions?window=1h`).then(r => r.json());

if (regressions.length > 0) {
const message = regressions
.map(r => `${r.metric} on ${r.pathname}: ${r.p75_before}ms → ${r.p75_current}ms (${r.percent_change}%)`)
.join('\n');

await fetch(SLACK_WEBHOOK, {
method: 'POST',
body: JSON.stringify({
text: `Core Web Vitals Regression:\n${message}`,
blocks: [{
type: 'section',
text: { type: 'mrkdwn', text: message },
}],
}),
});
}
}

// Run every 5 minutes
setInterval(monitorRUMRegressions, 5 * 60 * 1000);

Root-Cause Debugging a Regression

When an alert fires, follow this systematic approach:

1. Isolate the Scope

-- Which page routes regressed?
SELECT pathname, metric, p75_before, p75_current, percent_change
FROM regression_analysis
WHERE timestamp > DATE_SUB(NOW(), INTERVAL 24 HOUR)
AND percent_change > 10
ORDER BY percent_change DESC;

-- Which devices/networks regressed?
SELECT effective_type, metric, p75_before, p75_current, percent_change
FROM regression_by_network
WHERE timestamp > DATE_SUB(NOW(), INTERVAL 24 HOUR);

If only mobile/slow-4G regressed, the issue is likely code execution or image size (not server latency). If all devices regressed equally, suspect server-side changes (API latency, deployment).

2. Check Recent Deployments

# View recent deployments
git log --oneline -10

# Compare bundle size changes
npm run build
ls -lh dist/

# Check git diff for the last commit
git diff HEAD~1 HEAD src/

If bundle size increased dramatically (>100 KB), suspect new dependencies or code. Use webpack-bundle-analyzer to identify what changed:

npm install --save-dev webpack-bundle-analyzer
# In vite.config.js or webpack.config.js, enable the plugin

3. Check Network and Cache

// Check if images or API responses are larger
fetch('/api/products', { method: 'HEAD' })
.then(r => console.log('API response size:', r.headers.get('content-length')));

// Check cache headers
fetch('/')
.then(r => console.log('Cache-Control:', r.headers.get('cache-control')));

If cache headers are missing or set to max-age: 0, every user re-fetches assets (LCP slows).

4. Profiling with Lighthouse in Staging

Run Lighthouse on the affected route in staging:

lighthouse https://staging.example.com/products --view

Look at the timing breakdown:

  • "First Contentful Paint" (FCP) timing reveals if the HTML/CSS/font is slow.
  • "Largest Contentful Paint" (LCP) timing reveals if the image is slow.
  • Performance opportunities section highlights actionable fixes.

5. Field Data Drill-Down

Use your RUM data to drill down:

// Which React components rendered slowest on the slow page?
import { Profiler } from 'react';

<Profiler id="ProductList" onRender={onRenderCallback}>
<ProductList />
</Profiler>

function onRenderCallback(
id,
phase,
actualDuration,
baseDuration,
startTime,
commitTime
) {
console.log(`${id} (${phase}): ${actualDuration}ms`);
}

Components with render time > 100 ms are bottlenecks. Use React.memo(), useMemo(), or code-splitting to optimize.

Comparing Before/After

After applying a fix, verify improvement:

# Run Lighthouse before and after
lighthouse https://staging.example.com --output-path before.json
# (apply fix)
lighthouse https://staging.example.com --output-path after.json

# Compare
npm install --save-dev lighthouse-compare
lighthouse-compare before.json after.json

Expected improvements:

  • LCP: -200 to -500 ms per optimization (code-splitting, image optimization, etc.)
  • INP: -50 to -200 ms per optimization (useTransition, memoization, etc.)
  • CLS: -0.01 to -0.05 per optimization (font-display: swap, image dimensions, etc.)

Rollback Strategy

If a regression is severe and the fix is unclear, rollback immediately:

# Rollback last deployment
git revert HEAD
npm run build
npm run deploy

# While rolled back, investigate root cause in a staging environment

Losing 1–2 hours to a regression is worse than delaying a feature by 1 day.

Key Takeaways

  • Set performance budgets 5–10% stricter than "good" to prevent regressions from being deployed.
  • Use Lighthouse CI to block PRs that exceed budgets.
  • Monitor field data (CrUX or RUM) daily for regressions; alert the team immediately.
  • Debug systematically: scope (which routes/devices), deployments (git log), network (cache headers), profiling (Lighthouse, React Profiler).
  • Verify fixes before assuming they work; compare before/after metrics.
  • Have a rollback plan for severe regressions.

Frequently Asked Questions

What is a "acceptable" regression?

A temporary regression (5–10%) caused by a feature launch is acceptable if you plan to optimize afterward. A permanent regression (> 10% without a plan) requires immediate action. Always prioritize Core Web Vitals over feature launches.

How often should I run performance budgets?

Run on every PR (in CI/CD). Run Lighthouse against production daily to catch regressions not caught in staging (e.g., real-user traffic patterns).

What if Lighthouse passes but CrUX fails?

Lighthouse tests a simulated device; CrUX is real users. If they differ, test on real slow devices or with network throttling:

# Simulate Slow 4G in DevTools Network tab, then run Lighthouse

Real users are the truth source. Optimize for CrUX, use Lighthouse for debugging.

How do I optimize without breaking other metrics?

Every optimization has trade-offs. Test the full suite (all three Core Web Vitals) before deploying:

lighthouse https://yoursite.com --view

Review all three Core Web Vitals, not just the one you're optimizing.

Further Reading