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.

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.

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 would let /login triple its payload undetected. Per-route limits make each regression visible at its source.

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.

  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.

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" }
  ]
}

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.

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 — attach a budget file per route:

{
  "ci": {
    "collect": { "url": ["https://staging.example.com/dashboard"] },
    "assert": {
      "assertions": {
        "resource-summary:script:size": ["error", { "maxNumericValue": 320000 }]
      }
    }
  }
}

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.

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.