All writing
3 min read

A Frontend Performance Budget Your Team Will Actually Keep

Performance work fails for organisational reasons, not technical ones. A budget only survives if it is small, automatic and owned by a name.

  • Performance
  • Tooling
  • Process
A Frontend Performance Budget Your Team Will Actually Keep

Every team I have joined says it cares about performance. Almost none of them have a number.

Without a number, "this feels slow" is an opinion, and opinions lose to whoever is most senior in the room. A budget turns a taste argument into a build failure, which is a much shorter conversation.

Pick four numbers, not fourteen#

The failure mode of performance budgets is quantity. A dashboard with thirty metrics gets looked at twice.

These are the four I have managed to keep alive on real teams:

  1. Largest contentful paint under 2.0s on a mid-tier Android over 4G.
  2. Interaction to next paint under 200ms on the same device.
  3. Initial JS, gzipped under a ceiling that only ever gets lowered.
  4. Total transferred bytes for the critical route.

Everything else is a diagnostic, not a budget. Bundle analysers and flame graphs are how you find the problem; the four numbers above are how you decide whether you have one.

Make the ceiling ratchets, not gates#

The single most effective rule I have used is this: the ceiling is whatever you are at today, minus a little. No new budget is ever set aspirationally, because a failing pipeline on day one gets disabled on day two.

json
{
  "budgets": [
    { "path": "/", "maxJsGzipKb": 180 },
    { "path": "/blog/*", "maxJsGzipKb": 210 },
    { "path": "/checkout", "maxJsGzipKb": 165 }
  ]
}

When a route is at 178KB, the ceiling goes to 180. Next sprint it goes to 175. Slow ratchets are permanent; aspirational gates are theatre.

Split by route, not by vibes#

The reason the blog route can afford a markdown renderer and the checkout route cannot is that they are different routes. Code splitting only pays if you split where the user actually branches.

tsx
const BlogPost = lazy(() => import("./routes/BlogPost"));
const Checkout = lazy(() => import("./routes/Checkout"));

Then verify it. A lazy() that still ends up in the entry chunk because something imported it eagerly three files away is worse than no splitting at all, because now you believe you did the work.

Measure on the device your users have#

This is the part teams skip. A 200KB bundle on an M-series laptop is fine. On the Android device that most of our merchants' staff actually use, parsing and executing that bundle is the difference between a tool and a waiting room.

Our rule was blunt: no performance PR is merged without a trace from a throttled profile. Not a synthetic score — a trace.

Give it an owner#

Budgets die from neglect, not from disagreement. Ours has a name on it and a recurring fifteen minutes in the sprint, and the job is not "improve performance". The job is to look at four numbers and say which one moved.

A budget with no owner is a wish. A budget with an owner and a build step is a policy.

What it changes day to day#

The practical effect is not that the app gets faster every sprint. It is that it stops getting slower, quietly, through a hundred reasonable-sounding additions.

And when someone genuinely needs to spend the budget — a new payment rail, a richer reconciliation view — there is a real conversation about what to trade, instead of a silent regression that nobody notices for two quarters.