Enforcing Per-Route Size Budgets in CI
Bundle analysis tells you the size today; a budget stops it creeping up tomorrow. This guide wires size-limit to a per-route budget so a pull request that pushes a route chunk over its gzip limit fails CI, with the delta posted on the PR. It operationalizes the enforcement step from bundle analysis and size budgeting; the routes it budgets are the ones produced by code-splitting strategies for large applications.
The reason this problem exists is that bundle size regresses in small, individually reasonable increments, and none of those increments is visible at review time. A developer adds a date library to one route, another imports an icon set as a namespace instead of by name, a third pulls a charting dependency into a component that renders on the dashboard. Each diff looks fine. The transfer size of the route grows twenty kilobytes at a time, and because nobody measures it on the way in, the first signal is a field report about a slow page months later. By then the regression is spread across dozens of commits and no single one is worth reverting. A budget converts that slow, untraceable drift into a hard failure on the exact pull request that caused it, while the author still has the context to fix it cheaply.
Where this sits in the build pipeline matters. The budget runs after the production build, against the emitted chunks in dist/, not against source. That is deliberate: what ships to a user is the minified, tree-shaken, gzipped output, and only the bundler knows the final byte count after resolution, dead-code elimination, and compression. Measuring source lines or node_modules weight would be measuring the wrong thing — a 400 kB dependency that tree-shakes down to 6 kB should not trip a gate, and a 20 kB dependency that drags in a polyfill graph should. So the gate is downstream of the bundler and upstream of merge: build, measure each route chunk’s compressed size, compare against a number you committed to the repo, and fail the check if any route is over.
The consequence of not having this gate is not a single dramatic failure; it is the absence of a floor. Without a committed budget, “is this route too big?” is a judgment call every reviewer re-litigates from scratch, usually by not litigating it at all. With one, the answer is a number in version control that a machine checks on every PR, and raising it becomes a deliberate, reviewable act rather than an accident.
Prerequisites & reproducible setup
# Vite 5.4.x, Node 20+, size-limit 11.1.0
npm install --save-dev size-limit @size-limit/preset-app
# Build with a route-split so each route is its own chunk.
npx vite build
ls dist/assets # expect index-*.js plus route chunks like about-*.js
@size-limit/preset-app measures gzip and brotli of the given files. Your build must emit stable, route-based chunk names (via manualChunks or dynamic import()) so a budget can target each route.
The preset is a bundle of three plugins: @size-limit/file, which reads the file from disk and applies compression; @size-limit/webpack or the equivalent that would re-bundle from an entry (unused here because you point at already-built files); and @size-limit/time, which estimates parse-and-execute time on a throttled CPU. For a static site or an already-bundled SPA you want the file-based path — you have a dist/ directory of real chunks, so there is no reason to let size-limit re-bundle. Pointing path at the emitted file means size-limit measures exactly the bytes the CDN will serve, hashes and all, rather than a second bundling pass that might diverge from your production Vite config.
Stable chunk names are the load-bearing prerequisite. Vite defaults to [name]-[hash].js, where [name] derives from the dynamic import specifier or the manualChunks key. If a route is imported with import('./routes/Dashboard.jsx'), the emitted chunk is Dashboard-<hash>.js, and the <hash> changes whenever the chunk’s content changes. That is why every budget path below is a glob — you match on the stable stem and let the hash float. If your routes end up with generic names like index-<hash>.js because you lazy-load through a barrel file, fix the naming first with an explicit manualChunks function or a named dynamic import; a budget cannot point at a chunk you cannot name.
If your route names are not stable, the smallest reliable fix is an explicit chunking function in the Vite config. This forces every route module into a chunk keyed by a name you control, so the glob in the budget never drifts:
// vite.config.js — Vite 5.4.x, force stable route chunk names
import { defineConfig } from 'vite'
export default defineConfig({
build: {
rollupOptions: {
output: {
// Key each lazy route into a predictable chunk name.
manualChunks(id) {
const m = id.match(/\/routes\/([^/]+)\//)
return m ? `route-${m[1].toLowerCase()}` : undefined
},
},
},
},
})
How size-limit measures a chunk
Understanding what the number means keeps you from arguing with it later. When size-limit runs against a path, it reads the raw bytes of every file the glob matches, concatenates them if the glob matched more than one, and then compresses the result in memory. With gzip: true (the default for the file preset) it runs the bytes through zlib at the maximum compression level and reports the compressed length. With brotli enabled it does the same through the brotli encoder. This is why the number is smaller than the ls -l size and why it is the right number: browsers negotiate content-encoding and receive the compressed stream, so the compressed length is the bytes actually crossing the wire.
The compression is done fresh on each run, not read from a .gz sidecar file, so the measurement is independent of whether your CDN pre-compresses. That independence is the point — you are budgeting the content, and content that compresses well (long repeated identifiers, predictable JSON) genuinely costs less to ship than content that does not (already-minified vendor code, base64 blobs). A regression that adds a large base64 image inline will show up far heavier in the gzip number than its raw size suggests, which is the correct signal.
One subtlety: gzip and brotli disagree by a few percent, and brotli is what most modern CDNs actually serve. If you budget on gzip but ship brotli you are being slightly conservative, which is fine. If a specific route is latency-critical, measure it on the encoding you deploy so the budget reflects reality rather than a proxy.
Diagnosis workflow
- Confirm routes are separate chunks. If a route’s code sits in the entry chunk, you cannot budget it independently — split it first.
- Pick the metric. Use gzip for transfer-sensitive route entries; add a
timelimit for the critical route if parse time matters. - Decide realistic limits. Set each budget slightly above today’s measured size so the gate catches regressions without failing on the current, accepted size.
Take each of those in turn, because the failure modes are different. Confirming a route is a separate chunk is a matter of reading dist/assets after a build and matching each route to a file — if about-*.js exists, the route is separable; if the About page’s code is only reachable through index-*.js, it is not, and any budget you write for it will either match the whole entry (wrong number) or match nothing (silently passing). The confirmation is mechanical: build, list the assets, and grep the emitted chunk for a string you know is unique to that route’s component. If the string is in index-*.js, the route was never split.
Picking the metric is a trade-off between what is cheap to measure and what the user feels. Gzip transfer size is cheap, deterministic, and correlates with download time on a constrained connection, so it is the default budget for every route. Parse-and-execute time is what the user feels on a slow phone once the bytes arrive, but it is noisier — it depends on the simulated CPU and varies run to run. Reserve a time budget for the one or two routes where main-thread cost is the actual complaint, and keep the rest on gzip. Budgeting time on every route buys noise, not signal.
Deciding realistic limits is the step teams get wrong most often, in both directions. Set the limit at exactly today’s size and the next trivial commit fails the gate for no real regression, training everyone to bump the number reflexively until the budget means nothing. Set it far above today’s size and you have a gate that never fires, which is worse than no gate because it looks like coverage. The rule that works: measure the current gzip size, add roughly ten to fifteen percent of headroom, round to a memorable number, and commit that. Headroom absorbs normal churn; the fixed ceiling still catches a real jump. When someone needs more room, they raise the number in a diff a reviewer can see and question.
Annotated solution config
// .size-limit.json — size-limit 11.1.0, one entry per route chunk
[
{
"name": "entry (initial load)",
"path": "dist/assets/index-*.js",
"limit": "60 kB", // gzip by default
"gzip": true
},
{
"name": "route: dashboard",
"path": "dist/assets/dashboard-*.js",
"limit": "45 kB"
},
{
"name": "route: reports (heavy charts)",
"path": "dist/assets/reports-*.js",
"limit": "120 kB"
}
]
# .github/workflows/size.yml — fail the PR when a route exceeds its budget
name: size
on: pull_request
jobs:
size:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci && npm run build
# size-limit exits non-zero if any entry is over budget.
- run: npx size-limit
Read the config as a flat list of independent assertions. Each object names one route, points path at that route’s chunk with a glob so the content hash can float, and states a limit as a human-readable size string that size-limit parses ("45 kB" is 45 000 bytes; use "45 KiB" if you mean 46 080). The name is cosmetic but load-bearing for the developer reading a failed run — make it the route the reader would recognise, not the filename. The entry chunk gets its own budget because the initial load is the one every user pays regardless of which route they land on; the lazy routes get budgets scaled to what they legitimately need.
The gzip: true on the entry is explicit only for documentation — it is the default, and the other entries inherit it. If you want to assert brotli instead, add "brotli": true to an entry and drop gzip; size-limit will then compare the brotli-compressed size against the limit. Do not enable both on the same entry expecting two independent checks; a single entry produces a single measurement against a single limit. To gate a route on both encodings, list it twice with different names.
The workflow is intentionally boring. It triggers on pull_request so the gate runs on the merge candidate, checks out the code, sets up Node with npm caching so npm ci is fast, builds, and runs npx size-limit. The build step is not optional — size-limit measures dist/, so a stale or missing dist/ from a previous job would measure the wrong thing or nothing. Keeping build and check as separate run lines makes a failed build distinguishable from a failed budget in the Actions log, which matters when you are triaging a red check at speed.
Posting the delta on the pull request
Failing the check is enough to block a merge, but it is not enough to teach the author what grew. For that, post the measured sizes back onto the PR so the reviewer sees the number next to the diff. The andresz1/size-limit-action wraps size-limit, runs it against both the base branch and the PR, and comments a table of before/after per entry. It reads the same .size-limit.json, so there is nothing new to maintain:
# .github/workflows/size.yml — comment the per-route delta on the PR
name: size
on: pull_request
permissions:
pull-requests: write # required so the action can post a comment
jobs:
size:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: andresz1/size-limit-action@v1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
# It builds base and head, then diffs every .size-limit.json entry.
build_script: build
The mechanism is a two-build diff: the action checks out the base branch, builds and measures it, then checks out the PR head, builds and measures that, and subtracts. The comment shows each route’s old size, new size, and the change, so a fifteen-kilobyte jump on the reports route is a line the reviewer reads before approving rather than a mystery discovered in production. The permissions: pull-requests: write block is required on modern GitHub Actions or the comment step will fail with a 403 even though the token exists; the token itself is the built-in GITHUB_TOKEN, no secret to provision. If your repo runs PRs from forks, note that fork PRs get a read-only token by default and the comment will be skipped — that is a GitHub security boundary, not a config error, and the exit-code gate still fires.
Verification
# size-limit 11.1.0 — run locally exactly as CI does.
npm run build && npx size-limit
# Simulate a regression: import a heavy lib into a route and rebuild.
# size-limit now prints the route over budget and exits 1.
A passing run prints each route’s size against its limit and exits 0. Push a change that bloats a route and the same command prints the offending route in red and exits non-zero, failing the PR check — the regression is blocked before merge.
The value of verifying locally is that the local command is byte-for-byte the CI command, so there is no class of failure that only appears in CI. Run npm run build && npx size-limit before pushing and you get the exact verdict the gate will give. To prove the gate actually bites rather than passing vacuously, deliberately break it once: import a known-heavy dependency into a single route, rebuild, and confirm that route now prints over budget and the process exits 1. A gate you have never seen fail is a gate you cannot trust; the deliberate regression is the test of the test.
Confirm the exit code explicitly rather than trusting the console colour, because CI keys off the exit code, not the text:
# Verify the failing exit code the CI check depends on.
npm run build && npx size-limit; echo "size-limit exit code: $?"
# 0 = all routes within budget; 1 = at least one route over.
If that prints 0 after your deliberate regression, the budget is not matching the chunk — almost always a glob that no longer matches the emitted filename, which size-limit treats as “nothing to measure” and passes. Fix the path, not the limit.
Gotchas & edge cases
- Hashed filenames. Use a glob (
dashboard-*.js); a fixed name stops matching after the next content hash. - Shared vendor double-counting. If routes share a vendor chunk, budget the vendor once and the route-only chunks separately, or you count vendor bytes per route.
- Zero-headroom budgets. A limit equal to today’s exact size fails on the next one-line change; leave a small margin.
- Unsplit routes. A route living in the entry chunk cannot be budgeted alone — split it with dynamic
import()first.
Hashed filenames are the failure that hides, because a path that no longer matches does not error — size-limit finds zero files and reports zero bytes, comfortably under any limit, so the check goes green while measuring nothing. The symptom is a budget that never fails no matter what you add; the root cause is a path pinned to a specific hash that a rebuild has since changed; the fix is a glob on the stable stem (dashboard-*.js); confirm it by re-running the deliberate-regression test and watching the route go red. Treat a budget that has never fired with suspicion.
Shared vendor double-counting is a correctness error in what the number means. When Vite hoists a common dependency into a shared vendor chunk, that chunk is loaded once and cached across routes, but a naive per-route budget that globs everything a route loads will count the vendor bytes against every route that pulls it in. The symptom is route budgets that all feel mysteriously tight and move together when you touch a shared dependency; the root cause is the same vendor bytes counted N times; the fix is one budget for the vendor chunk and route budgets that point only at the route-specific chunk. Confirm by checking that changing a vendor dependency moves the vendor budget and nothing else.
Zero-headroom budgets fail on churn, not regressions. A limit set to the current byte count exactly will trip on the next whitespace-changing refactor because minified output is not byte-stable across trivial edits. The symptom is red checks on diffs that plainly did not add features; the root cause is a ceiling with no slack above normal variance; the fix is the ten-to-fifteen-percent headroom rule above. Confirm by making a no-op change to the route’s source, rebuilding, and verifying the size wobbles by a few bytes but stays under.
Unsplit routes cannot be measured at all, which is the one gotcha that is upstream of size-limit entirely. The symptom is that you cannot write a path that isolates the route because its code is inside index-*.js; the root cause is that the route was never given its own chunk; the fix is a dynamic import() or a manualChunks entry for it, exactly as the diagnosis section covers. Confirm by rebuilding and checking that a chunk named for the route now exists in dist/assets. Budgeting is strictly downstream of splitting — there is no config that measures a route that does not exist as a chunk.
When per-route budgets are the wrong tool
A per-route gate is the right instrument for an application that route-splits and ships chunks whose sizes you want to hold steady. It is the wrong instrument in a few cases worth naming so you do not force it. A site with a single bundle and no meaningful splitting has exactly one number to watch — a total-bundle budget, not a per-route one, and the extra config buys nothing. A library published to npm cares about the exported entry points and tree-shakeability, not route chunks; budget the package entry and its sideEffects behaviour instead. And an app in heavy early churn, where routes are added and renamed weekly, will spend more time maintaining glob paths than the gate saves — turn it on once the route topology has settled. The budget is a ratchet for a stable structure, not a design tool for one still in flux.
Comparison with bundlesize and other gates
size-limit is not the only tool that does this, and the alternatives shape the config differently. bundlesize (now largely unmaintained) does the same glob-and-compare against gzip but has no time metric and a thinner plugin model; if you are choosing today, prefer size-limit for the maintained toolchain and the time option. Webpack’s built-in performance.maxAssetSize and maxEntrypointSize warn or error during the build itself rather than as a separate step, which is convenient if you are on webpack but couples the gate to the bundler and does not give you the per-route naming control you get from an external config. Rollup and Vite have no equivalent built-in, which is precisely why an external tool pointed at dist/ is the idiomatic answer in this ecosystem. Bundle-analyzer visualisations answer “what is in the bundle” but enforce nothing; they are the tool you reach for after a budget fails, to attribute the bytes, which is why the source-map-explorer workflow pairs with this one. The division of labour is clean: size-limit blocks the regression, the explorer explains it.
Related
- Bundle analysis and size budgeting — the parent cluster on measurement and duplication.
- Reading a bundle with source-map-explorer — attributing the bytes a budget then guards.
- Code-splitting strategies for large applications — producing the route chunks these budgets target.