Chapter 30
The Performance Budget
Defining and enforcing a performance budget for React SPAs — Core Web Vitals (INP, LCP, CLS), CI gates that block regressions, and RUM vs lab telemetry.
Published 2026-05-23
🎯 Chapter Goal — After this chapter you can define Core Web Vitals targets that fit your app’s shape (INP, LCP, CLS), enforce them in CI so regressions don’t ship, and read RUM data to know whether budgets reflect real-user reality.
🧭 Prerequisites — None for the budget concept; Ch 17 (Vite) for the build-time tooling.
🔹 30.1 Core Web Vitals for SPAs
Three metrics matter in 2026:
- LCP (Largest Contentful Paint) — when the main content paints. Target: ≤ 2.5 s for 75th percentile users.
- INP (Interaction to Next Paint) — how responsive interactions feel. Target: ≤ 200 ms for 75th percentile. Replaced FID in 2024 as the official metric.
- CLS (Cumulative Layout Shift) — visual stability. Target: ≤ 0.1.
For shell SPAs specifically:
- LCP often means the first useful render of the post-auth shell, not the empty login screen.
- INP is the killer metric — heavy hydration, big component trees, render storms all spike INP.
- CLS is usually fine in SPAs (no late-loading content) — except in tables/lists where missing skeleton rows cause shifts.
🔹 30.2 Defining a budget
A performance budget is a number you commit to. Three flavors:
Bundle-size budget
// lighthouse-budget.json or similar
{
"resourceSizes": [
{ "resourceType": "script", "budget": 250 }, // KB, gzip
{ "resourceType": "stylesheet", "budget": 50 },
{ "resourceType": "image", "budget": 200 },
{ "resourceType": "total", "budget": 600 }
]
}
Web Vitals budget
{
"categories": { "performance": 85, "accessibility": 95 },
"audits": {
"largest-contentful-paint": { "maxNumericValue": 2500 },
"interactive": { "maxNumericValue": 3500 },
"cumulative-layout-shift": { "maxNumericValue": 0.1 }
}
}
Per-route budget
Different routes have different budgets. The login page can be tiny; the dashboard can afford to be larger.
{
"/login": { "script": 100 },
"/app/*": { "script": 350 }
}
Setting the number
Start by measuring your current state. Set the budget at the current value + small headroom (5–10 %). Lower it as you optimize. Don’t set aspirational budgets — you’ll just ignore CI failures.
🔹 30.3 Enforcing budgets in CI
The pattern: every PR gets a preview deploy; Lighthouse runs against the preview; fails on budget violation.
# .github/workflows/perf.yml
- uses: vercel/action@v2
id: preview
- uses: treosh/lighthouse-ci-action@v11
with:
urls: |
${{ steps.preview.outputs.url }}
${{ steps.preview.outputs.url }}/app/dashboard
budgetPath: ./lighthouse-budget.json
uploadArtifacts: true
The preview URL changes per PR (Ch 19 §19.7). Lighthouse runs against the real deployment, not a synthetic dev build — what you measure is what users get.
Bundle-size CI separately — Lighthouse runs the full audit; bundle-size budgets can fail faster on the build output without spinning up a browser. Use Node so the units match your lighthouse-budget.json (which is in gzipped KB) and the check runs identically on Linux / macOS / Windows runners:
📄 scripts/check-bundle-size.mjs
import { readdirSync, readFileSync } from 'node:fs';
import { gzipSync } from 'node:zlib';
import { join } from 'node:path';
const LIMIT_KB = 600;
const dir = 'dist/assets';
const total = readdirSync(dir)
.filter((f) => f.endsWith('.js'))
.reduce((sum, f) => sum + gzipSync(readFileSync(join(dir, f))).length, 0);
const totalKb = total / 1024;
console.log(`Bundle gzip size: ${totalKb.toFixed(1)} KB (limit ${LIMIT_KB} KB)`);
if (totalKb > LIMIT_KB) { console.error('✗ over budget'); process.exit(1); }
- name: Bundle size budget
run: node scripts/check-bundle-size.mjs
Now the budget compares apples to apples — both numbers are gzipped, both portable.
🔹 30.4 RUM vs lab — closing the loop
Lab = Lighthouse in CI. Synthetic. Repeatable. Cheap. Catches regressions before they ship.
RUM (Real User Monitoring) = real users’ browsers reporting their Web Vitals. Noisier; reflects actual user devices, networks, geos.
You need both. Lab alone means you ship “good Lighthouse, bad real user experience.” RUM alone means you find out about regressions a week after they ship.
Wiring RUM
import { onLCP, onINP, onCLS } from 'web-vitals';
const report = (metric: { name: string; value: number; rating: string }) => {
fetch('/api/rum', {
method: 'POST',
body: JSON.stringify(metric),
keepalive: true,
});
};
onLCP(report);
onINP(report);
onCLS(report);
The backend sinks into Datadog RUM, Sentry Performance, or a homegrown setup. Dashboards by route, by device, by geo, by tenant.
The closed loop:
- Lab budgets keep new regressions from shipping.
- RUM tells you when reality diverges from lab.
- When RUM degrades, update the lab budget to catch the cause. Now your lab matches reality again.
🪤 Common Pitfalls
- Setting aspirational budgets nobody enforces → cargo cult.
- Measuring only Lighthouse — no RUM → ship “fast on M2 Pro, slow on a Pixel 6”.
- Per-app budget when per-route would catch regressions earlier.
- Lighthouse run against a local dev build — not what users get.
- INP measured only on click handlers — most INP issues come from layout work, not handlers.
- Skipping CLS budgets because “SPAs don’t have layout shifts” — tables and lists do.
✅ Recap
- LCP, INP, CLS — the three Core Web Vitals.
- Define budgets as numbers; start where you are, ratchet down.
- Lab budgets in CI catch regressions; RUM closes the loop with reality.
- Per-route budgets catch what per-app budgets hide.
🔗 Further Reading
- https://web.dev/vitals/ — Web Vitals overview (LCP / INP / CLS).
- https://www.npmjs.com/package/web-vitals —
web-vitals4.x library for client-side reporting. - https://github.com/treosh/lighthouse-ci-action — Lighthouse CI Action (v11) for GH Actions.
- Ch 31 (bundle optimization), Ch 32 (runtime perf), Ch 33 (network).
In the book — not on the site
Each topic has an 🧠 Under-the-hood subsection — the algorithm, the data structures, what React DevTools surfaces, debugging recipes. Plus a 🧪 hands-on lab per chapter with a starter repo. Reserved for the book.