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:
| Metric | Good Threshold | Budget | Tolerance |
|---|---|---|---|
| LCP | ≤ 2.5s | ≤ 2.2s | 12% buffer |
| INP | ≤ 200ms | ≤ 180ms | 10% buffer |
| CLS | ≤ 0.1 | ≤ 0.08 | 20% 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
- Lighthouse CI Documentation — Official setup guide and assertions reference.
- Web.dev: Performance Budgets — How to set realistic budgets.
- CrUX API — Programmatic access to field data for alerting.
- React Profiler API — Measure component render times to identify bottlenecks.