How to Enforce JavaScript Bundle Budgets with size-limit

This guide implements the size half of JavaScript Performance Budgets, within JavaScript Bundle Optimization & Code Splitting. size-limit is a small CLI that measures files or import paths after bundling and compression and fails with a non-zero exit code when any exceeds its limit. It is deterministic, fast enough to run on every commit, and its output is readable by people who have never opened a bundle analyser.

The tool is simple; using it well is not. Budgets must cover the files a route actually loads (not whatever a glob happens to match), measure the size that matters for each concern, and be set at levels that hold the line without failing every other build. This guide covers configuration for app bundles and libraries, the measurement options, and the CI wiring that makes failures actionable.

size-limit in a pull request pipeline Steps from building the app in CI to measuring each budget, failing on overage and reporting the diff to the author. size-limit in a pull request pipeline Build production output Measure per budget entry Compare limit + base branch Fail or pass exit code Report PR comment

Rapid Diagnosis

  • Do you have any byte budget today? If regressions are noticed only in Lighthouse or RUM, start here.
  • Do budgets cover routes or files? File-level budgets break when chunk names change and miss weight that moves between files.
  • Which size do they measure? Gzip-only budgets under-predict parse cost; uncompressed-only budgets ignore transfer.
  • Do failures explain themselves? A red check with no diff gets rerun or overridden; a diff showing "+42KB: date-fns locale added" gets fixed.

Root Cause Analysis: Why Budgets Get Ignored

1. Wrong coverage. Globs that match too much (all chunks) produce a total nobody can act on; globs that match too little miss the real initial load.

2. Unrealistic limits. Budgets set at aspirational targets fail constantly and get disabled.

3. Noisy or slow checks. Running timing-based checks on every commit introduces flakiness; size checks must stay deterministic.

4. No ownership. Nobody is responsible for deciding whether a failing budget should be raised or the change reworked.

size-limit measurement options The measurement modes size-limit supports and what each is good for. size-limit measurement options Option Measures Use for gzip: false, brotli: false uncompressed bytes Parse and compile cost brotli: true brotli bytes Transfer on modern CDNs import: '{ X }' cost of importing one export Library consumers running: true (@size-limit/time) estimated execution time Rough CPU signal; noisy

Step-by-Step Resolution

1. Install and define budgets per route

bash
npm install --save-dev size-limit @size-limit/file
# For libraries measuring import cost through a bundler:
# npm install --save-dev @size-limit/esbuild
javascript
// .size-limit.cjs
module.exports = [
  { name: 'Landing — initial JS (raw)',    path: ['dist/assets/index-*.js', 'dist/assets/framework-*.js'], limit: '320 KB', gzip: false, brotli: false },
  { name: 'Landing — initial JS (brotli)', path: ['dist/assets/index-*.js', 'dist/assets/framework-*.js'], limit: '98 KB',  brotli: true },
  { name: 'Product route chunk (raw)',     path: 'dist/assets/ProductPage-*.js', limit: '90 KB', gzip: false, brotli: false },
  { name: 'Checkout route chunk (raw)',    path: 'dist/assets/CheckoutPage-*.js', limit: '70 KB', gzip: false, brotli: false },
];
// trade-off: two budgets per route (raw + brotli) double the maintenance but
// catch both kinds of regression: a minified-but-huge module (raw) and an
// incompressible asset like inlined images (brotli).

Expected outcome: npx size-limit prints each budget with its current size and fails if any is over.

2. Generate route file lists from the manifest

Globs are brittle; a small script can produce the exact file list for each entry from the build manifest and write it to a JSON file that .size-limit.cjs reads.

javascript
// scripts/route-files.mjs — writes dist/route-files.json { "src/main.ts": ["assets/index-a1.js", ...] }
import { writeFileSync } from 'node:fs';
import manifest from '../dist/.vite/manifest.json' with { type: 'json' };
const closure = (k, s = new Set()) => (s.has(k) ? s : (s.add(k), (manifest[k].imports ?? []).forEach((d) => closure(d, s)), s));
const out = {};
for (const [k, v] of Object.entries(manifest)) if (v.isEntry || v.isDynamicEntry) out[k] = [...closure(k)].map((x) => `dist/${manifest[x].file}`);
writeFileSync('dist/route-files.json', JSON.stringify(out, null, 2));
// trade-off: this follows static imports only. Code that calls import() during
// startup still loads extra chunks; add those to the budget explicitly.
javascript
// .size-limit.cjs (manifest-driven)
const routes = require('./dist/route-files.json');
module.exports = [
  { name: 'Landing initial', path: routes['src/main.ts'], limit: '320 KB', gzip: false, brotli: false },
  { name: 'Checkout route',  path: routes['src/pages/Checkout.vue'], limit: '140 KB', gzip: false, brotli: false },
];
// trade-off: requiring a generated file means size-limit must run after the
// build script. Wire both into one npm script so they cannot run out of order.

Expected outcome: budgets follow the real import graph through chunking changes.

3. Run in CI and fail on overage

yaml
# .github/workflows/size.yml
name: size
on: pull_request
jobs:
  size:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: npm }
      - run: npm ci
      - run: npm run build && node scripts/route-files.mjs
      - run: npx size-limit
# trade-off: this fails the check but does not explain the change. Add the
# per-PR comment from the next guide so authors see the diff without digging.

Expected outcome: a pull request that pushes any route over budget cannot merge without an explicit decision.

A budget failed — what now? Decision sequence for responding to a failed size-limit budget in a pull request. A budget failed — what now? Is the new code needed on this route's first load? If not, move it behind import() — budget passes yes no Is there a lighter alternative dependency? Swap it — and record the saving yes no Can existing weight be removed to pay for it? Remove it in the same PR yes no Raise the budget with a written justification and owner sign-off

4. Ratchet budgets down after improvements

When an optimisation lands, lower the relevant budget to the new size plus a small margin in the same pull request. Budgets that only ever go up stop meaning anything.

Verification

Introduce a deliberate regression on a branch — import a large library eagerly in the landing entry — and confirm the size check fails with a clear message naming the route. Then remove it and confirm the check passes. Check that the measured landing size roughly matches the transfer size in the Network panel for the same build (brotli) and the decoded size (raw); large discrepancies mean the budget covers the wrong files.

Worked Example: Catching a Locale Explosion

A team added date formatting to a booking widget with import { format } from 'date-fns' and, for translated month names, import * as locales from 'date-fns/locale'. The pull request diff was three lines. The landing budget failed: raw initial JavaScript went from 298KB to 512KB, because the namespace import pulled every locale into the entry chunk. The failure message showed the route and the size jump; the author replaced the namespace import with a dynamic import(\date-fns/locale/${lang}`)` for the user's language, and the budget passed at 301KB. Without the budget, the regression would have shipped to every page, adding roughly 20ms of parse time on mid-tier phones for a widget most users never opened.

Common Mistakes

  • Budgeting the whole dist/ folder. A total over all chunks includes lazy routes most users never load; it hides regressions on the critical path and penalises adding lazy features.
  • Forgetting CSS. Render-blocking CSS affects LCP as much as JavaScript affects INP; add CSS budgets for the main stylesheet.
  • Using @size-limit/time as a gate. Execution-time estimates vary with the CI machine; treat them as informational.
  • Letting the base branch drift above budget. If main already fails, every PR fails and the check is ignored. Fix main first.

FAQ

Can size-limit measure a library's cost for consumers?

Yes. With @size-limit/esbuild (or the webpack plugin) and the import option, it bundles a tiny entry that imports specific exports and measures the result — the cost a consumer pays for import { Button } from '@acme/ui'. That is the right budget for libraries, where tree-shakeability matters more than total file size.

How tight should the margin above current size be?

Three to five percent for raw-size budgets on routes that change often, tighter for stable ones. The margin exists so routine fixes do not fail CI; anything larger lets meaningful regressions slip through unnoticed.

Does size-limit work with webpack and Next.js?

Yes — it measures files, so any bundler's output works. For Next.js, budget the files listed in the build manifest for each page, or use the "First Load JS" figure Next.js prints, which is the per-route initial JavaScript total.