Budgets

Turn "the site feels slower" into a number that fails a build — and keep every improvement you make.

14of 16
≈ 12Min read
5Parts

Performance is a ratchet, not a project

Every site gets slower by default. Each sprint adds a feature, a script, a tag, an image; nobody adds a millisecond deliberately, and yet the total goes up every month. A performance week fixes it, and six months later it is back.

The only thing that holds is a budget: a number that a build fails against. Not a goal, not a dashboard — a failure. Everything else gets deprioritised eventually.

Two kinds of data, and you need both

Field data tells you what to fix. Lab data tells you whether you fixed it — and stops the next person from breaking it. A budget needs the lab number, because only that one can block a merge.

Why p75 and not the average: an average is dragged down by the fast half and hides the tail. p75 asks "is this good for three users out of four?", which is a question with a defensible answer.

Field (RUM)Lab (synthetic)
What it isReal users, real devices, real networksA scripted run on a fixed machine
Answers"Are people having a bad time?""Did this change make it worse?"
Noisy?Not in aggregate, but slow to reactReacts instantly, but a single run is noise
Reported atp75 — three users in fourMedian of several runs
Toolsweb-vitals + your own endpoint, CrUXLighthouse CI, WebPageTest, your own script

What to put a number on

If you are already over, do not set the target — set today’s number plus a few per cent and ratchet it down. A budget nobody can pass is a budget everybody disables in week two.

BudgetTypical starting point
LCP2500 ms
INP200 ms
CLS0.1
TTFB800 ms
Total JavaScript, compressed150–170 KB for the first load
Total page weight1 MB
Request count50
Third-party scriptsA count, and a named owner for each

Making the build fail

numberOfRuns: 5 is not optional — one Lighthouse run is noise, and a flaky gate is a disabled gate. Compare medians, and put the budget in the repository next to the code it constrains.

lighthouserc.json — assertions, in CI, on every pull request
{
  "ci": {
    "collect": { "url": ["https://staging.example/"], "numberOfRuns": 5 },
    "assert": {
      "assertions": {
        "largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
        "cumulative-layout-shift":  ["error", { "maxNumericValue": 0.1 }],
        "total-blocking-time":      ["error", { "maxNumericValue": 300 }],
        "resource-summary:script:size": ["error", { "maxNumericValue": 175000 }]
      }
    }
  }
}

Collecting the field number yourself

sendBeacon survives the page being closed, which matters because the final INP value is only known at the end of the visit. Store the raw samples, not averages — you cannot recover a percentile from a mean, and the percentile is the whole point.

the whole of real user monitoring
import { onLCP, onINP, onCLS } from "web-vitals";

const send = (metric) => navigator.sendBeacon("/vitals", JSON.stringify({
  name: metric.name, value: metric.value, id: metric.id, path: location.pathname,
}));

onLCP(send); onINP(send); onCLS(send);

Practice