Every team we meet has a performance target. Very few have a performance budget.
The distinction is not semantic. A target is a number you would like to hit. A budget is a number that, when exceeded, causes something to not ship. If nothing is ever cancelled, you have a target with better branding — and targets erode, quarter by quarter, in increments too small to argue about.
How sites get slow
Nobody makes a site slow in one commit. It happens through a hundred locally reasonable decisions.
A 34KB date library, because writing the formatting by hand felt like premature optimisation. An analytics tag, because marketing asked and it is only 12KB. A carousel on the homepage, because a competitor has one. A font weight, because the design uses it in two places.
Each is defensible in isolation. Nobody is ever confronted with the aggregate, because the aggregate has no owner and appears in no review. Eighteen months later the homepage weighs 4.8MB and everyone is surprised.
The budget has to be per-route and in CI
Two properties make a budget real.
Per-route, not site-wide. A site-wide average lets a heavy page hide behind a light one. Budgets belong on the routes people actually land on, measured as the bytes those routes ship.
Enforced by the pipeline, not by a person. If a human has to notice, they eventually will not — and when they do, they are arguing with a colleague under deadline pressure rather than pointing at a failed build. The build failing is not an opinion, and that is precisely its value.
Our default for a content route is 180KB of JavaScript, compressed. Applications get more. The exact number matters far less than that it exists and is enforced.
What it feels like when it works
On the Orbital Retail replatform we cut three planned features during the build. Not deprioritised — cut, because they breached the route budget and the trade was not worth it.
One was a live-inventory widget on product pages that would have added 40KB for information already present a scroll further down. The product manager was, reasonably, unhappy. We showed the number, showed what 40KB costs at the 75th percentile on 4G in their slowest market, and offered a server-rendered version at 2KB. That shipped instead.
That conversation only happened because the build failed. Without it, the widget ships, nobody measures, and the cost shows up six months later as an unattributable regression.
Measure the right thing
Two mistakes are common.
Measuring on your own machine. A MacBook on office fibre tells you nothing about a mid-range Android on a congested network, which is the median for most consumer products. Test on throttled hardware or you are measuring your own privilege.
Measuring averages. The average is dominated by your fastest users. The 75th percentile is where the people who are about to leave live. Core Web Vitals uses p75 for exactly this reason, and your internal dashboards should too.
What we actually enforce
Per landing route, at p75 on a throttled mid-range device:
- LCP under 1.8s. Usually means the hero is server-rendered text, not an image that arrives late.
- CLS under 0.05. Every image and embed has reserved dimensions. No exceptions, because the exception is always the one that shifts.
- INP under 150ms. Long tasks get broken up or moved off the main thread.
- 180KB of compressed JavaScript. The one that actually forces architectural decisions.
The last is the one that generates arguments, and the arguments are the point. It is the only budget that makes a team ask whether a feature needs to be interactive at all — and the answer, surprisingly often, is that it does not.