Enforcing Per-Route JavaScript Budgets
A single global JavaScript ceiling treats a marketing landing page and a data-heavy admin dashboard as if they cost the same, which they never do. One global cap is set wide enough to fit the heaviest route, so every lighter route silently carries slack a regression can fill without ever tripping the gate. This guide, part of the JavaScript Bundle Size Limits reference, replaces the global cap with distinct per-route budgets and a CI assertion that fails on the specific entrypoint that regressed, so a 40 KB jump on /dashboard cannot hide behind headroom on /about.
The principle is one budget per entrypoint, each derived from that route's job. A login page should ship almost nothing; an interactive dashboard earns more. The gate names the offending route so the fix is unambiguous. Sizing those ceilings from field data is the same discipline covered in Choosing Between P75 and P90 Budget Targets; this page focuses on splitting one number into many and wiring the gate.
Why One Global Cap Leaks
Picture five routes measured against a single 150 KB initial-JavaScript cap chosen for the P75 high-end mobile user on 4G. The dashboard sits right at the line, but the landing page ships 52 KB and the login page just 30 KB. The gap between what a light route actually ships and the shared ceiling is pure slack: a developer can add a date picker, a charting library, or an unused polyfill to /login and grow it from 30 KB to 120 KB while the global gate stays green, because 120 KB is still under 150 KB. The regression only becomes visible in the field, weeks later, when login abandonment ticks up on slow connections.
Per-route budgets close that gap by drawing the ceiling just above each route's real cost. The diagram below plots the same five routes: each crimson bar is the route's current initial JS, the green tick is its own per-route ceiling, and the dashed line is the old global cap. The tall unguarded space under the global line on /login and / is exactly the slack a regression exploits.
Per-Route Budget Table
The table below assigns a brotli initial-JavaScript ceiling per route, sized to the route's interactivity rather than a one-size cap. These are starting points for the P75 high-end mobile user on 4G; calibrate against your own field data before locking them into the gate.
| Route / entrypoint | Role | Initial JS ceiling (brotli) | Total transfer ceiling |
|---|---|---|---|
/ (landing) |
Mostly static, marketing | 60 KB | 180 KB |
/login |
Single form | 45 KB | 140 KB |
/dashboard |
Interactive, data-heavy | 150 KB | 320 KB |
/reports/[id] |
Charts + export | 130 KB | 300 KB |
/settings |
Forms + tabs | 90 KB | 220 KB |
The spread between /login and /dashboard is the whole point: a global cap of 150 KB for the P75 high-end mobile user on 4G would let /login triple its payload undetected. Per-route limits make each regression visible at its source. The total-transfer column exists because initial JavaScript is not the only cost a route carries; a route can pass its script ceiling yet blow past a sane total once CSS, fonts, and hydration data are counted, so both columns are gated.
How to Size Each Route Budget
Do not invent ceilings by intuition. Classify each route by the job it performs for the user, then measure the current cost and set the ceiling a small margin above it so the budget ratchets down over time rather than blessing today's bloat. Three broad classes cover most sites, and the decision tree below maps a route's job to a starting range for the P75 high-end mobile user on 4G.
Routes with heavy client-side navigation, such as a React Router application, deserve their own treatment because a single entry chunk hydrates many views; Route-Level Bundle Budgets for React Router covers splitting those budgets along lazy route boundaries rather than server-rendered pages.
Diagnostic Steps
-
Measure per-route bytes from the build output. Next.js prints first-load JS per route directly:
npm run buildExpected output: a route table with a
First Load JScolumn, e.g.○ /dashboard 148 kB. Note any route already near its target for the P75 high-end mobile user on 4G. -
Map chunks to routes for non-framework builds by emitting and inspecting the manifest:
npx vite-bundle-visualizer -o report.htmlExpected output: a treemap grouping each entry chunk; confirm each route's entry glob (
assets/dashboard-*.js) and its current brotli size. -
Separate shared code from route code. A vendor chunk imported by every route inflates each route's first-load figure. Before setting a ceiling, decide whether the shared chunk belongs in a common budget or in each route's number. The cleanest approach is one shared-vendor budget plus a per-route budget that counts only route-specific code, so a change to a widely-shared dependency does not silently charge every route at once.
Implementation
Configure bundlesize with one entry per route glob so each entrypoint is bounded independently. The role-prefixed filenames from your build config make these globs stable.
{
"bundlesize": [
{ "path": "dist/assets/landing-*.js", "maxSize": "60 kB", "compression": "brotli" },
{ "path": "dist/assets/login-*.js", "maxSize": "45 kB", "compression": "brotli" },
{ "path": "dist/assets/dashboard-*.js", "maxSize": "150 kB", "compression": "brotli" },
{ "path": "dist/assets/reports-*.js", "maxSize": "130 kB", "compression": "brotli" },
{ "path": "dist/assets/settings-*.js", "maxSize": "90 kB", "compression": "brotli" },
{ "path": "dist/assets/vendor-*.js", "maxSize": "70 kB", "compression": "brotli" }
]
}
The trailing vendor-* entry is the shared-code budget from step 3: it fences the dependency chunk every route pulls in, so an accidental import of a 90 KB library into the shared graph fails on vendor-* rather than pushing five route budgets over at once.
On Next.js, enforce a per-page first-load ceiling natively in the bundle analyzer config so the framework fails the build per page rather than per glob:
// next.config.js
const withBundleAnalyzer = require("@next/bundle-analyzer")({
enabled: process.env.ANALYZE === "true",
});
module.exports = withBundleAnalyzer({
experimental: {
// Warn when any page's first-load JS exceeds the per-page ceiling.
largePageDataBytes: 128 * 1000,
},
});
CI Gating Assertion
This GitHub Actions step runs bundlesize, which exits non-zero and names the breaching route. Because each route is a separate entry, the failure message points at the exact entrypoint. The build feeds measured per-route sizes into the gate, and the gate resolves to a pass or a route-named failure — the flow the diagram below traces end to end.
name: Per-Route JS Budget
on:
pull_request:
branches: [main]
jobs:
route-budget:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- run: npm ci
- run: npm run build
- name: Enforce per-route budgets
run: npx bundlesize
To assert the same ceilings through Lighthouse CI per URL — useful when you run the matrix described in GitHub Actions Performance Matrices, or the single-PR gate in Running Lighthouse CI on Every Pull Request — attach a budget file per route:
{
"ci": {
"collect": { "url": ["https://staging.example.com/dashboard"] },
"assert": {
"assertions": {
"resource-summary:script:size": ["error", { "maxNumericValue": 320000 }]
}
}
}
}
The maxNumericValue of 320000 matches the /dashboard total-transfer ceiling of 320 KB from the budget table, expressed in raw bytes as Lighthouse reports them, so the two gates agree on the same number rather than drifting apart.
Verification
Run the gate locally and confirm a deliberate regression fails on the right route:
npm run build && npx bundlesize
A passing run prints one line per route, e.g. PASS dist/assets/dashboard-9f2a.js: 147 kB <= 150 kB (brotli). Import a heavy library into /login and re-run; the output must read FAIL dist/assets/login-*.js specifically, not a generic total — that confirms the gate localizes the regression. Require the route-budget check in branch protection so the breaching route cannot merge.
Two failure modes are worth rehearsing before you trust the gate. First, a new route with no budget entry should fail review, not pass silently: treat a missing bundlesize entry the way you treat a red check, so every route that ships also ships a ceiling. Second, a shared-chunk regression should land on the vendor-* entry rather than smearing across five routes; verify this by importing a large dependency into shared code and confirming only vendor-* reports FAIL. Rehearsing both keeps the gate honest as the app grows.
Frequently Asked Questions
Why not just set one global JavaScript cap?
A global cap must be wide enough for your heaviest route, so every lighter route carries slack. A regression that adds 40 KB to a light page passes because the global total still fits. Per-route budgets remove that slack by bounding each entrypoint to its own job, so a regression trips the gate at its source. See JavaScript Bundle Size Limits for the global-cap baseline this refines.
How do I keep per-route globs stable as routes change?
Use role-prefixed chunk names (dashboard-[hash].js) and match on the prefix glob. When you add a route, add a corresponding bundlesize entry in the same PR; a route with no budget entry should fail review, so missing budgets are caught the same way regressions are.
Where should shared vendor code count?
Give shared code its own budget, such as a vendor-* glob, and keep each per-route budget for route-specific code only. That way a change to a widely-imported dependency fails on the shared entry once, instead of quietly charging every route and pushing several over at the same time.
What number should a route budget start at?
Classify the route by job — static, single form, or interactive app — then measure its current brotli initial JS and set the ceiling a small margin above it for the P75 high-end mobile user on 4G. Starting ranges are roughly 55 to 70 KB for static, 40 to 50 KB for a single form, and 130 to 160 KB for an interactive dashboard. Ratchet the ceiling down as you optimize.
Should the total-transfer ceiling be gated too?
Yes. Initial JavaScript is not the only cost a route carries, and a route can pass its script ceiling while CSS, fonts, and hydration data push total bytes past a sane limit. Gate both columns so a route cannot trade one budget for the other, keeping the P75 high-end mobile on 4G experience within reach.