Most performance budgets are horoscopes. A wiki page says the app should stay under some number, nobody measures it on every change, and eighteen months later someone opens the network tab and gasps. I have written several of those wiki pages myself. They did nothing.
ClaroCal’s budgets live in a JSON file in the repo, and CI fails the build when a change exceeds any of them. Page weight in kilobytes, HTTP request count per page, database queries per route, and the total size of the client and server bundles are all gated. The landing page is allowed 13 requests. The dashboard is allowed 45, and 8 database queries. Exceed any number by one and the pull request goes red.
That part is fairly standard, at least in theory. The part that changed how we build is the ratchet.
Two jaws
Each deterministic budget has two jaws. Exceed it, and CI fails: that is the regression jaw. Come in well under it, and a script tightens the budget down to the new measured value: that is the ratchet jaw. Delete a dependency and save 30KB, and the budget drops 30KB. The headroom you freed cannot be quietly spent by the next feature; whoever wants it back has to say so in a pull request, out loud.
The client bundle, measured on every build.
Not every budget ratchets, and the reason is worth stealing. Timing metrics like first-contentful-paint run on a shared CI machine whose speed varies from run to run. Ratchet a noisy number toward its best measured value and you get a gate that fails randomly, and a gate that fails randomly gets ignored, and an ignored gate is worth less than no gate, because it still costs attention. So the timing budgets are fixed alarms set far above normal, there to catch a genuine five-to-tenfold blowup. All of the ratcheting pressure lives on the deterministic metrics: same build in, same numbers out, tightened to the byte.
We ship around 25 milliseconds of server response time and 84 milliseconds to first paint against alarms set at 400 and 600. The slack looks absurd on purpose. The tight numbers are elsewhere, on the things a build can actually promise.
The ceiling starts making decisions
Here is what I did not expect: once the ceiling is real, it stops being a test and starts being a participant in design reviews. Three examples from the last few months.
The duration corpus stayed on the server. ClaroCal infers how long a task will take from a 478-row corpus of everyday tasks. The instinctive design puts that table in the browser so the quick-add chip can be instant. The client bundle was at 89% of its ceiling, so that design was never on the table. The corpus lives behind an endpoint, the chip fetches after a debounce, and the accidental benefit was the better product: one matcher, one truth, no client copy to drift out of sync. The constraint did not compromise the feature. It found the right shape for it.
The recurrence module loads on demand. The chip row shows how many occurrences a repeating task will create, computed by the same horizon function the server uses. A static import would put that module in the dashboard’s initial graph: one more request on every single page load, paid by everyone, for a feature most sessions never touch. The request-count budget made that cost visible, so the module loads the first time someone actually types a recurring phrase. Almost every session never pays for it.
A warning chip reuses an icon. Our icon library ships one module per icon, so a new icon is a new request. A warning variant of the repeat chip wanted its own distinct icon, and the budget said no. The amber colour and the wording carry the warning fine. Would anyone have noticed one more request? No. That is exactly how the previous 30 accumulate.
The pattern in all three: without a gate, each of these defaults to the convenient version, because the cost of “one more request” is invisible at review time and enormous in aggregate. The budget converts an invisible aggregate cost into a visible per-change cost. Nothing else about it is clever, and nothing else needs to be.
Saying the raise out loud
A hard ceiling only works if raising it is allowed. Ours gets raised routinely: the server bundle budget has a comment trail recording every raise and why, a redesign here, a new route group there, each with the measured number at the crossing. At one point the headroom was down to 0.7KB and the comment says so, as a warning to the next person.
That trail turned out to be the most useful documentation in the repo. It is a ledger of what every feature actually weighed. When someone asks why the app got heavier this quarter, the answer is written down, feature by feature, in the file that enforced it.
The failure mode this prevents has a shape everyone has seen: no single change made the app slow, so nobody is responsible, so nothing gets removed. With the ledger, weight has an author. Not for blame; the raises were all approved. For memory.
Takeaways
- A budget that does not fail the build is a wish. Put the numbers in the repo and make CI enforce them. The wiki-page version costs the same to write and does nothing.
- Ratchet what is deterministic, alarm what is noisy. Tighten byte counts and query counts automatically; give timing metrics fixed thresholds with huge slack. A flaky gate trains people to ignore all gates.
- Freed headroom should not be silently spendable. The ratchet means savings persist and spending is a decision. Otherwise every optimisation is on loan to the next careless feature.
- Expect the ceiling to design things. The best outcomes were not features squeezed under the limit, but features reshaped by it: server-side corpus, on-demand modules. If a budget never changes a design, it is set too loose to matter.
- Keep the raise ledger. Budget raises with a one-line reason, in the file, forever. It is the cheapest architectural history you will ever write.