tech, developers, and the code underneath

issue 196· essay·

The performance budget

A number, agreed in advance, that fails the build. The only mechanism that has ever kept a system fast.

Every system starts fast and gets slower. Not because of one bad decision — because of a hundred small ones, each of which added two milliseconds, none of which anyone could reasonably object to.

The only mechanism I have seen reliably prevent this is a budget: a number, agreed in advance, that fails the build when exceeded.

why "we should keep it fast" does not work#

Because "fast" has no threshold, so there is never a moment where a specific change is the problem.

Every individual addition is defensible. The library is 12 KB and saves a week. The query is 8 ms and enables a feature. The middleware is 3 ms and improves security.

Ten of those and your page is 400 ms slower, and no single change was wrong. There was no point at which anyone could say no, because the comparison was always "this change versus nothing" rather than "this change versus the budget."

A budget changes the comparison. Now the question is "what are you willing to remove to make room for this," which is a real conversation.

setting the numbers#

Derive them from user-facing outcomes, not from what you currently have.

For a web frontend:

metricbudget
JavaScript, compressed170 KB
CSS, compressed60 KB
Largest Contentful Paint (p75, mobile)2.5 s
Interaction to Next Paint (p75)200 ms
Total requests, initial load40

For an API:

metricbudget
p50 latency50 ms
p99 latency500 ms
database queries per request10
memory per instance512 MB

Per-layer budgets are the underrated part. A single end-to-end number tells you something regressed. Per-layer budgets tell you where.

total request budget: 200 ms
  auth:        10 ms
  validation:   5 ms
  database:    80 ms
  business:    40 ms
  serialize:   15 ms
  overhead:    50 ms

When the database layer goes to 120 ms, that specific budget fails, and the person who changed the query knows immediately rather than in a month when someone investigates a general slowdown.

This is borrowed directly from game development, where per-subsystem frame budgets have been standard practice for decades.

enforcement#

The budget must fail something, or it is a wish.

In CI, on every pull request:

yaml
- name: bundle size
  run: npx size-limit          # fails if over the configured budget

- name: performance test
  run: k6 run --threshold 'http_req_duration{p(99)}<500' load.js

Report the delta on the pull request. "This change adds 8 KB to the main bundle (142 KB → 150 KB, budget 170 KB)." Visible, in context, at the moment of decision.

Allow overrides with a written reason. Not blocking forever — blocking until somebody says why. The record of overrides is itself useful; if you are overriding every week, the budget is wrong or the system is losing.

the budget for query count#

The single most useful backend budget, and the least common.

Count database queries per request in tests. Assert a maximum.

python
with assert_max_queries(10):
    client.get("/api/orders")

This catches N+1 queries at the moment they are introduced, which is the only cheap time to catch them. An N+1 that reaches production is found weeks later by someone investigating a slow endpoint, and by then it is embedded in an ORM relationship that four other things depend on.

Almost every framework has a way to do this, and almost nobody does.

what happens when you exceed it#

The conversation that a budget forces, in order:

  1. Can we make the new thing cheaper? Lazy load it, defer it, make the query better.
  2. Can we remove something else? This is the valuable one. It surfaces the feature nobody uses and the library that is doing 5% of what it costs.
  3. Should the budget change? Sometimes yes, deliberately, with a reason recorded. A budget that never changes is a budget that will be ignored.
  4. Do we not ship this? Rare and it should be available.

Any of those is better than the default, which is that the change lands and the system is permanently slower.

the thing to measure first#

Before setting a budget, get the current numbers and the distribution. p50, p75, p95, p99. On real user hardware and real networks, not on a developer laptop on office wifi.

Then set the budget at roughly where you are, and ratchet it down over time rather than setting an aspirational number you fail immediately.

A budget you exceed on day one gets disabled on day two.

get README in your inbox

One dispatch, no noise. Tech and developer news, plus the occasional long piece on the craft.

subscribe →