modern-react-spa

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:

  1. Lab budgets keep new regressions from shipping.
  2. RUM tells you when reality diverges from lab.
  3. When RUM degrades, update the lab budget to catch the cause. Now your lab matches reality again.

🪤 Common Pitfalls

  1. Setting aspirational budgets nobody enforces → cargo cult.
  2. Measuring only Lighthouse — no RUM → ship “fast on M2 Pro, slow on a Pixel 6”.
  3. Per-app budget when per-route would catch regressions earlier.
  4. Lighthouse run against a local dev build — not what users get.
  5. INP measured only on click handlers — most INP issues come from layout work, not handlers.
  6. 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

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.

Topics in this chapter (4)
  1. 30.1 Core Web Vitals for SPAs
  2. 30.2 Defining a budget
  3. 30.3 Enforcing budgets in CI
  4. 30.4 RUM vs lab — closing the loop