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 is | Real users, real devices, real networks | A 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 react | Reacts instantly, but a single run is noise |
| Reported at | p75 — three users in four | Median of several runs |
| Tools | web-vitals + your own endpoint, CrUX | Lighthouse 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.
| Budget | Typical starting point |
|---|---|
| LCP | 2500 ms |
| INP | 200 ms |
| CLS | 0.1 |
| TTFB | 800 ms |
| Total JavaScript, compressed | 150–170 KB for the first load |
| Total page weight | 1 MB |
| Request count | 50 |
| Third-party scripts | A 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.
{
"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.
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);