Performance Budgets That Survive Contact With a Product Roadmap
Most performance work follows the same arc. Someone notices the app has gotten slow. A week is spent profiling, code-splitting, and deleting a dependency nobody needed. The numbers look good, a chart gets posted, everyone is pleased. Six months later the app is slower than it was before the effort started.
The work was real. The problem is that it was a project rather than a constraint. Performance is not a state you reach - it is a property that decays continuously under normal product development, because every feature added is weight added and nothing in the process pushes back.
A performance budget is the thing that pushes back. It only works if it is enforced automatically, because a budget that depends on human vigilance loses to a deadline every time.
Measure What Users Experience
Before setting a budget, be clear about what you are measuring, because the two common measurement setups disagree and both are necessary.
Lab metrics come from a synthetic run in a controlled environment. Reproducible, available before you ship, and the only thing you can gate a pull request on. They are also a fiction - a specific device on a specific connection, which is nobody.
Field metrics come from real users on real devices and networks. This is the truth, and it is the only data that reflects the mid-range Android phone on a congested network that represents a real share of your traffic. It also arrives after you ship.
You need both, doing different jobs: field data to decide what the budget should be and whether it is working, lab data to enforce it on every change. Setting a budget without field data means you are optimizing for the laptop you develop on.
Look at percentiles, never averages. The mean is dominated by fast sessions on good hardware. The 75th percentile is where real users live, and the 95th is where the complaints come from.
If your performance data comes from your own machine, you are measuring your hardware budget, not your users.
Set Budgets on Things People Can Act On
A budget on a composite score is hard to act on. When it regresses, nobody knows what to do. Budget on specific, attributable quantities.
-
JavaScript shipped per route, compressed, split by first-party and third-party. This is the number that correlates most directly with interaction delay, and it is the one that creeps most reliably.
-
Total page weight per route, which mostly means images in practice.
-
Largest Contentful Paint and Interaction to Next Paint, at the 75th percentile in the field.
-
Request count on the critical path, particularly blocking requests, and particularly ones to third-party origins.
-
Third-party script weight, budgeted separately. Keeping this in its own line item is deliberate. It is usually a meaningful fraction of the total, it is the part engineers did not choose, and separating it makes that visible in the conversation where a fourth analytics tag is being proposed.
Budget per route, not per application. A marketing landing page and an authenticated dashboard have genuinely different constraints, and one global number means the strict pages subsidize the loose ones.
Set the number just above where you are today, not at some aspirational target. A budget you are already failing gets ignored within a week. A budget with a few percent of headroom starts holding the line immediately, and you ratchet it down as you make improvements.
Enforce in CI or Do Not Bother
This is the part that determines whether any of the above matters.
The check runs on every pull request, compares against the base branch, and comments with the delta: this change adds 14 KB to the checkout route, which is 4 KB over budget. It fails the build when the budget is exceeded.
What makes it survive rather than get disabled:
Report the delta, not just the absolute. "This PR adds 14 KB" is actionable. "The bundle is 312 KB" is context-free and gets ignored.
Attribute the increase. The check should name what grew - which module, which dependency. Without that, the author has to go find it themselves, and a check that creates work without providing information is one people learn to route around.
Provide a documented override. Sometimes a feature genuinely justifies the weight. An explicit, labeled exception that gets recorded is far better than the alternative, which is someone quietly raising the threshold in a config file. The record is what lets you review accumulated exceptions later.
Keep it fast and deterministic. A check that takes twelve minutes or fails intermittently will be disabled inside a month, and it will be disabled during a crunch when you need it most.
What Actually Moves the Numbers
When a budget is exceeded, the fixes cluster into a short list.
Ship less JavaScript. Route-level splitting so a page loads only its own code. Defer anything not needed for first interaction. Audit dependencies - a date library, an icon set imported wholesale, a component library where three components are used. This is where most of the weight is and most of the wins are.
Move work off the client. Rendering on the server, or at build time, removes both the JavaScript that would have done the work and the delay of doing it on a slow device. This is the structural fix, and it is why the framework-level rendering decision matters more than any amount of micro-optimization afterward.
Handle images properly. Correct dimensions, modern formats, explicit width and height to avoid layout shift, lazy loading below the fold. Almost entirely mechanical, and frequently the largest single reduction available on a content-heavy page.
Get fonts under control. Subset them, self-host, preload the one used above the fold, and set a sensible display strategy. A blocking font request on the critical path delays the first thing users see.
Constrain third parties. Load them after interaction where possible, and treat every new tag as a budget request rather than a configuration change. This is a policy problem more than a technical one.
Diagnosing a Regression
When the budget check fails, the fix is usually quick if you look in the right order.
Read the bundle analysis first. Nearly every regression is one of three things: a new dependency, an existing dependency imported in a way that defeats tree-shaking, or code that was in an async chunk moving into the main bundle. The third is the sneakiest, because it typically comes from an innocuous-looking import added at the top of a shared module.
Check whether a dynamic import got hoisted. A single static import of a component that was previously lazy-loaded pulls it and its entire dependency graph into the initial bundle. The diff looks like one line.
Look at what a shared module now pulls in. A utility file imported by every route is the highest-leverage place for accidental weight. Adding one heavy import there multiplies across the application.
Confirm it is not the measurement. Occasionally the budget check itself changed - a config update, a build tool upgrade altering chunking, a different compression setting. Rule this out before rewriting application code.
For field regressions with no bundle change, look at the network waterfall. A third-party script that got slower, an API endpoint on the critical path that degraded, or an image that was replaced with an unoptimized version will move the field numbers without touching a single byte of your JavaScript.
Performance for Users You Are Not Measuring
The gap between lab and field data is mostly a gap in whose experience is being represented, and it is worth being deliberate about who is missing from your numbers.
Segment field data by device class. A single p75 across all traffic hides that the p75 on low-end Android is several times worse than on desktop. If those users matter commercially, they deserve their own line on the chart rather than being averaged away.
Segment by geography and connection. Latency to a single-region origin is not the same experience everywhere. If a meaningful share of users are far from where you deploy, that shows up in every request and no amount of bundle trimming fixes it.
Watch out for survivorship bias. Users whose experience is bad enough that they abandon before the page loads may never fire the metric that would have recorded how bad it was. Rising bounce rates alongside improving performance numbers is a signal worth taking seriously.
Test on real hardware occasionally. Throttling on a fast laptop approximates a slow network reasonably well and a slow CPU poorly. Having one genuinely mid-range device around, and using it, recalibrates the team in a way that dashboards do not.
Make the Cost Visible Where Decisions Get Made
The technical enforcement is necessary but it is not sufficient, because the decision to add the thing that breaks the budget usually happens before any code is written.
What works is putting the number in front of the people making those calls, routinely and without drama. Field metrics on the same dashboard as the business metrics. The bundle delta in the PR where the tradeoff is live. A brief note in planning when a proposed feature has an obvious weight implication.
None of that requires convincing anyone that performance matters in the abstract. It just makes the cost legible at the moment of the decision, which is the only moment it can be traded off honestly.
Where to Start
Get field data first - a week of real user metrics from real devices, broken down by route and percentile. It is usually worse than the team expects, and it tells you which two routes matter.
Then set budgets on those routes at slightly above current, wire a bundle-size check into CI that reports the delta with attribution, and fix whatever the first few failures surface. That is a week of setup, and unlike a performance sprint, it does not decay.
If your application has reached the point where this kind of discipline needs to be retrofitted alongside everything else, that is the territory our MVP to Production Engineering work covers.
Performance is not something you fix. It is something you stop letting regress.