JavaScript Performance Budgets: Holding the Line on What Ships
This topic extends JavaScript Bundle Optimization & Code Splitting from one-off optimisation to prevention. Every team that has shrunk a bundle has watched it grow back: a new dependency here, a feature flag library there, an analytics SDK added in a hurry before a launch. Six months later the landing route is heavier than before the cleanup, and the Core Web Vitals that improved have regressed. A performance budget is the mechanism that makes growth visible at the moment it happens — in the pull request — rather than in the next quarterly field-data review.
JavaScript is the right thing to budget first because it is the most expensive byte on the web. An image byte is decoded once, usually off the main thread; a JavaScript byte must be downloaded, parsed, compiled and executed, and every millisecond of that execution on the main thread competes with user input. Script cost drives Total Blocking Time in the lab and, through input delay and processing time, INP's 200ms threshold in the field. For client-rendered content it also gates LCP.
The Degradation Budgets Prevent
Budgets protect against three slow-moving failure modes that individual code reviews cannot see:
- Accretion. Each pull request adds a few kilobytes, each individually reasonable. Over a year, the landing route doubles. No single reviewer ever saw the total.
- Misplaced weight. A large dependency imported in a shared module lands in the entry chunk, so every route pays for a feature used on one. The diff that caused it was a one-line import.
- Silent tooling changes. A build configuration or dependency upgrade changes chunking or targets, and bundle sizes shift without any application code changing.
The thresholds budgets ultimately protect are the field ones — INP under 200ms and LCP under 2.5s at p75 — but budgets act on leading indicators: bytes, long tasks and lab timings that move immediately when code changes.
Prerequisites
- A production build in CI that produces the same chunks you deploy.
- A size tool:
size-limit,bundlesize,bundlewatchor a custom script over the build manifest. - Lighthouse CI (or WebPageTest/your own Puppeteer runs) for timing budgets on representative URLs with fixed throttling.
- RUM with route templates so field targets can be tracked per template, as described in RUM beacons and field data collection.
- An agreed owner for each budget — usually the team that owns the route or entry point.
1. Environment Setup: Decide What to Measure
Pick the unit of budget before the numbers. The most useful units are:
- Per entry point / route, not per file: users experience a route's total, not individual chunks.
- Uncompressed size for parse and compile cost; compressed size for transfer — track both, budget primarily on uncompressed.
- Initial load only, separately from lazily loaded chunks, which can have looser budgets.
2. Capture a Baseline
Record current sizes and timings for each budgeted route and set the initial budget slightly above today's values — not at an aspirational target. A budget that fails on day one gets disabled; a budget that holds today's line and is lowered after each optimisation keeps its authority.
// .size-limit.js — initial budgets at current size + ~5%.
module.exports = [
{ name: 'landing (initial JS)', path: ['dist/assets/index-*.js', 'dist/assets/framework-*.js'], limit: '190 KB', gzip: false, brotli: false },
{ name: 'landing (initial JS, brotli)', path: ['dist/assets/index-*.js', 'dist/assets/framework-*.js'], limit: '62 KB', brotli: true },
{ name: 'checkout route', path: 'dist/assets/Checkout-*.js', limit: '95 KB', gzip: false, brotli: false },
];
// trade-off: glob paths tie budgets to chunk naming. A chunking change can make a
// budget silently cover different files — keep names stable or budget via the
// manifest's per-entry closure instead.
3. Isolate What Each Budget Protects
Map each budget to the metric it guards. Initial-route uncompressed bytes guard parse cost and TBT; long-task count during load guards early INP; route-chunk size guards transition time; third-party request count guards both. When a budget fails, this mapping tells the author why it matters, which is the difference between a budget that gets respected and one that gets raised.
4. Apply: Enforce in CI and Review
Run size checks on every pull request and post the diff as a comment so authors see the cost of their change. Run timing budgets on a smaller set of URLs, with several runs and median aggregation to control noise. The guides in this topic cover each piece: enforcing bundle budgets with size-limit and tracking bundle size per pull request.
Deconstructing What a Kilobyte of JavaScript Costs
Converting bytes into time is what makes a budget defensible. On a mid-tier Android phone, rough figures for minified JavaScript are:
| Phase | Cost driver | Rough rate (mid-tier mobile) |
|---|---|---|
| Download | compressed bytes | ~6–10ms per 10KB brotli on a slow 4G link |
| Parse + compile | uncompressed bytes | ~1ms per 10–20KB, more for complex code |
| Evaluate | top-level module code | varies; side-effect-heavy modules dominate |
| Execute | application logic | depends on what runs at startup |
Parse and compile cost is why compressed size is the wrong budget on its own, and measuring JavaScript parse and compile cost shows how to get real numbers for your code on your target devices.
Advanced Diagnostics and Edge Cases
Budgets that measure the wrong files. After a chunking change, a glob may match a different set of files. Budget via the build manifest's per-entry import closure where possible, which follows the real graph.
Lazy chunks loaded at startup. A chunk is "lazy" in the build but loaded immediately by code that calls import() on mount. Size tools classify it as lazy; the browser does not. Cross-check budgets with a Network panel recording.
Third-party scripts outside the build. Tag-manager-injected scripts never appear in bundle budgets. Budget them separately with Lighthouse's resource counts or a RUM-based third-party size report.
Monorepos with shared packages. A change in a shared package can push several apps over budget at once. Run budgets for every consuming app in the shared package's CI, not only in the apps' own pipelines.
Server components and islands. In architectures where most code never ships to the client, budget the client manifest only; server code is not part of the browser's cost.
Validation and Budgeting Over Time
Budgets need maintenance rituals to remain meaningful:
# .github/workflows/perf-budgets.yml (excerpt)
- run: npm run build
- run: npx size-limit --json > size.json
- run: npx lhci autorun --config=lighthouserc.js
# trade-off: Lighthouse CI on shared runners is noisy. Use median-of-5 runs and
# a dedicated runner if timing budgets flake more than once a month.
Review budgets monthly: lower any budget where the route now sits well below it (bank the gain), and examine any budget raised in the period — each raise should have a linked justification. Publish a simple chart of budgeted sizes over time where the whole team sees it.
Choosing Which Routes to Budget
Budgeting every route is tempting and counterproductive: the check list grows, failures on rarely visited pages create noise, and attention spreads thin. Start with the routes that carry most of your traffic and most of your business value — typically the landing templates (home, category, product or article) and the conversion path (cart, checkout, sign-up). Use RUM to rank templates by page views and by their share of failing Core Web Vitals, and budget the top five to eight. Lazy routes reached only by navigation get a single, looser "any lazy chunk under N KB" rule that catches gross regressions without per-route maintenance.
As the system matures, add budgets for the shared chunks that every route loads — the framework and vendor-stable chunks — because a regression there affects everything at once. Those budgets belong to the platform team, while route budgets belong to the teams that own the routes.
Worked Example: Introducing Budgets to an Existing App
A team with a two-year-old React application and no budgets measured its eight most visited routes: initial JavaScript ranged from 280KB to 610KB uncompressed, and lab TBT on the mobile profile from 340ms to 980ms. Setting budgets at aspirational targets would have failed every build, so they set each route's budget at its current size plus 3%, and added a TBT assertion at current median plus 10%. In the first month the budgets failed twice — both for genuine regressions caught before merge. Meanwhile, three optimisation projects landed, and each lowered the relevant budget in the same pull request. After two quarters, the heaviest route's budget had been ratcheted from 628KB to 390KB without a single "raise the budget" debate, because the budgets had only ever been lowered to reflect work already done.
Budgets for Third-Party and Runtime-Injected Code
Bundle budgets only see what your build produces. Third-party tags injected by a tag manager, A/B testing snippets, chat widgets and consent platforms load at runtime and can easily exceed your own application code. Budget them with a different tool: Lighthouse CI's resourceSizes and resourceCounts for third-party in a budget.json, or a RUM report of third-party script bytes and LoAF-attributed execution time per vendor. Give each vendor an explicit allowance and an internal owner who approved it; when marketing wants a new tag, the conversation starts from the remaining allowance rather than from zero. Third-party script performance covers containment techniques once a vendor is over budget.
Making Budgets Part of the Culture
Budgets work when they are treated as product constraints, not as a performance team's private tripwire. Three practices help. Show the cost in the pull request as a human-readable sentence — "+38KB to the checkout route, about +15ms of parse time on a mid-tier phone" — so authors understand it without context. Give teams a way to "pay" for additions by removing weight elsewhere, which turns a blocked merge into a design conversation. And celebrate reductions as visibly as features: a chart of the landing route shrinking over a quarter does more for budget compliance than any lint rule.
Connecting Budgets to Field Outcomes
A budget is a hypothesis: "if this route stays under these numbers, its field INP and LCP will stay healthy". Test the hypothesis periodically. Plot each budgeted route's lab metrics against its RUM p75 over several months; if field INP worsens while every budget holds, the budgets are measuring the wrong thing — perhaps a third-party script, a data-dependent render cost, or interactions that happen long after load. Adjust what you budget rather than tightening the existing numbers. Conversely, when field metrics are healthy with ample margin, there is room to relax budgets on routes where features matter more than milliseconds. The goal is not the smallest possible bundle; it is a bundle whose size is a conscious decision tied to user experience.
FAQ
What is a good starting budget for initial JavaScript?
There is no universal number, but for content and commerce sites targeting mid-tier mobile devices, initial JavaScript under roughly 100–170KB compressed (300–500KB uncompressed) is a common range that leaves room inside the TBT and INP budgets. Derive yours from your own timing model, as in deriving performance budgets from Core Web Vitals, then start at today's size and ratchet down.
Should budgets block merges or only warn?
Size budgets, which are deterministic, should block. Timing budgets, which are noisy, should warn on small overages and block only on large ones — or block on the median of several runs. A budget that blocks on noise trains people to rerun CI until it passes.
Who should own the budget for a shared chunk?
The team that owns the platform or framework layer. Shared chunks are where accidental imports from feature teams land, so the owner needs authority to push back and to suggest dynamic imports instead.
Do budgets slow down feature development?
Rarely, in practice. Most pull requests are far below any budget; the check runs in seconds and passes silently. The cases where a budget bites are exactly the ones where a quick conversation saves a lasting regression — usually solved by a dynamic import or a lighter dependency within the same pull request.
Should budgets differ between mobile and desktop?
Usually there is one bundle for both, so byte budgets are shared; but timing budgets should be set for the mobile profile, where CPU cost is three to five times higher. If a route passes its mobile timing budget, it will pass on desktop. Where you ship genuinely different code per form factor — a mobile-only navigation drawer, a desktop-only data grid — budget those chunks separately and assign them to the team owning that experience.
What tooling do I need to start this week?
A production build in CI, size-limit with a handful of route budgets at current size plus a few percent, and a per-pull-request size comment. That combination takes an afternoon to set up and covers most regressions. Timing budgets in Lighthouse CI and field targets per template can follow once the habit of looking at size in review is established.
How do budgets interact with code splitting?
Splitting moves bytes from initial routes to lazy chunks, so it usually makes initial budgets easier to meet. Watch for the opposite failure: so many small lazy chunks that navigation becomes a cascade of requests. Pair route budgets with a check on chunk count per navigation, and look at transition time in RUM.
Guides in This Topic
- Enforcing bundle budgets with size-limit — byte budgets that fail CI.
- Tracking bundle size per pull request — making every change's cost visible.
- Measuring JavaScript parse and compile cost — turning bytes into milliseconds.
- Why compressed size is the wrong budget — choosing the right unit.
Related
- Best Lighthouse CI setup for frontend pipelines — the timing-budget half of enforcement.
- Webpack bundle analysis techniques — investigating a failed budget.
- Vite & Rollup build optimization — build settings that keep budgets achievable.
- Third-party script performance — the scripts your bundle budget cannot see.