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.

Three kinds of JavaScript budget Layers of JavaScript budgets from build-time byte limits through lab timing limits to field targets. Three kinds of JavaScript budget Size budgets uncompressed and compressed bytes per entry point and route — checked on every commit Timing budgets TBT, scripting time and long tasks in a throttled lab run — checked per pull request Field targets INP and LCP p75 per template from RUM — reviewed weekly Ownership a named owner and a documented process for changing each budget

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.

Landing-route JavaScript over a year without and with budgets Bar chart of a landing route's JavaScript size at four points in a year, comparing a team without budgets to one with enforced budgets. Landing-route JavaScript over a year without and with budgets Q1 (both teams) 180KB Q4 without budget 342KB Q4 with budget 196KB The budgeted team still grew, but every increase was a deliberate, reviewed decision.

Prerequisites

  • A production build in CI that produces the same chunks you deploy.
  • A size tool: size-limit, bundlesize, bundlewatch or 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.

javascript
// .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.

Budget to metric mapping Each common JavaScript budget, the Core Web Vital it protects, and where it is enforced. Budget to metric mapping Budget Protects Enforced in Initial JS (uncompressed) TBT, early INP size-limit on every commit Initial JS (brotli) LCP on slow networks size-limit Long tasks during load INP input delay Lighthouse CI Route chunk size transition time size-limit per route Third-party scripts count TBT, INP Lighthouse budget.json

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:

PhaseCost driverRough rate (mid-tier mobile)
Downloadcompressed bytes~6–10ms per 10KB brotli on a slow 4G link
Parse + compileuncompressed bytes~1ms per 10–20KB, more for complex code
Evaluatetop-level module codevaries; side-effect-heavy modules dominate
Executeapplication logicdepends on what runs at startup

What 200KB of uncompressed startup JavaScript costs on a mid-tier phone Timeline of download, parse and compile, and evaluation for 200 kilobytes of startup JavaScript on a mid-tier phone. What 200KB of uncompressed startup JavaScript costs on a mid-tier phone Network download (65KB brotli) Main thread parse + compile evaluate 0ms 100ms 200ms 300ms 400ms 500ms

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:

yaml
# .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.

Ratcheting the heaviest route's budget over two quarters Bar chart showing a route's JavaScript budget being lowered after each optimisation over six months. Ratcheting the heaviest route's budget over two quarters Month 0 (current + 3%) 628KB Month 2 (charts lazy-loaded) 512KB Month 4 (date library swapped) 446KB Month 6 (hydration split) 390KB

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