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.

Global cap versus per-route ceilings Five routes with their current initial JavaScript, individual per-route ceilings shown as green ticks, and a single high global cap line that leaves large unguarded slack on the lighter routes. 160 110 60 0 Initial JS (KB, brotli) Global cap 150 KB 60 52 45 30 147 130 122 90 78 / /login /dashboard /reports /settings Green tick = per-route ceiling; gap below the dashed line = unguarded slack
Under one global cap, /login could nearly quadruple its JavaScript and still pass; a per-route ceiling removes that slack.

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.

Sizing a route budget by job A root node classifies a route by job and branches to three leaf nodes giving starting initial-JavaScript ranges for static, single-form, and interactive routes. Classify route by the job it does Static / marketing Mostly content, little interaction 55 to 70 KB Single form Login, contact, one primary action 40 to 50 KB Interactive app Dashboards, charts, live data 130 to 160 KB Ranges are P75 high-end mobile on 4G starting points; set the ceiling just above today's measured cost
Classify by job, then anchor the ceiling a small margin above the route's current measured weight so the budget only ratchets tighter.

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

  1. Measure per-route bytes from the build output. Next.js prints first-load JS per route directly:

    npm run build

    Expected output: a route table with a First Load JS column, e.g. ○ /dashboard 148 kB. Note any route already near its target for the P75 high-end mobile user on 4G.

  2. Map chunks to routes for non-framework builds by emitting and inspecting the manifest:

    npx vite-bundle-visualizer -o report.html

    Expected output: a treemap grouping each entry chunk; confirm each route's entry glob (assets/dashboard-*.js) and its current brotli size.

  3. 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.

Per-route budget gate pipeline A build step emits per-route JavaScript sizes, bundlesize matches each route glob and compares to its ceiling, and the gate resolves to a pass or a failure that names the breaching route. npm run build emits chunks Per-route brotli sizes bundlesize glob vs ceiling PASS FAIL names /login Each route is a separate entry, so the failure message localizes the regression to one route instead of a single global total
Because every route is its own bundlesize entry, the gate fails with the breaching route's name rather than a single opaque total.
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.