Route-Level Bundle Budgets for React Router

A single-page app has one shell but many destinations, and the destinations are where the weight hides. This how-to is part of the Single-Page App Performance Budgets reference, and it shows the concrete mechanics: split a React Router application at the route boundary with React.lazy, emit one named JavaScript chunk per route, then attach a byte budget to each chunk and enforce it with size-limit in continuous integration. The goal is that no single route — the admin console, the reporting view, the chart-heavy dashboard — can quietly inflate and blow the whole application's budget while the home screen still looks fast.

Route-level budgeting is the natural companion to a global cap. A global initial-bundle limit tells you the front door is light; a per-route limit tells you the far rooms stay light too. If you have already read Enforcing Per-Route JavaScript Budgets, this page is the React Router-specific implementation of that idea, and it leans on the same dynamic-import mechanics covered in Budgeting for Dynamic Import Code Splitting.

Split at the Route Boundary With React.lazy

The route is the correct seam for code splitting because it is the unit a user navigates to. When you wrap a route element in React.lazy, the bundler treats the imported module as a split point and emits a separate chunk that is only fetched when that route mounts. React Router renders the lazy element inside a Suspense boundary, so the transition shows a fallback while the chunk downloads. Below is a route tree where every top-level destination is its own chunk, and the shared shell — router, layout, navigation — stays in the entry bundle.

import { lazy, Suspense } from "react";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import AppLayout from "./AppLayout";
import RouteFallback from "./RouteFallback";

const Home = lazy(() => import("./routes/Home"));
const Dashboard = lazy(() => import("./routes/Dashboard"));
const Reports = lazy(() => import("./routes/Reports"));
const Settings = lazy(() => import("./routes/Settings"));
const Admin = lazy(() => import("./routes/Admin"));

function withSuspense(element) {
  return <Suspense fallback={<RouteFallback />}>{element}</Suspense>;
}

const router = createBrowserRouter([
  {
    path: "/",
    element: <AppLayout />,
    children: [
      { index: true, element: withSuspense(<Home />) },
      { path: "dashboard", element: withSuspense(<Dashboard />) },
      { path: "reports", element: withSuspense(<Reports />) },
      { path: "settings", element: withSuspense(<Settings />) },
      { path: "admin", element: withSuspense(<Admin />) },
    ],
  },
]);

export function App() {
  return <RouterProvider router={router} />;
}

To make chunks addressable by a budgeting tool you need stable, predictable file names. With Vite and Rollup, give each dynamic import a magic-comment chunk name, or configure manualChunks so the output file for the Reports route is always reports-[hash].js. The hash changes with content; the human-readable prefix does not, and that prefix is what your budget config will glob against.

// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  build: {
    rollupOptions: {
      output: {
        chunkFileNames: "assets/[name]-[hash].js",
        manualChunks(id) {
          if (id.includes("/routes/Reports")) return "route-reports";
          if (id.includes("/routes/Dashboard")) return "route-dashboard";
          if (id.includes("/routes/Admin")) return "route-admin";
          if (id.includes("node_modules/react")) return "react-vendor";
        },
      },
    },
  },
});
Route to chunk to budget to gate Route definitions become named build chunks, each chunk maps to a size-limit entry, and the entries feed a pass or fail CI gate. One route, one chunk, one budget entry Route defs React.lazy split points Named chunks route-reports route-admin size-limit one entry per chunk CI gate pass / fail The shared shell stays in the entry bundle and is budgeted separately.
Each route definition compiles to a named chunk, each chunk owns one size-limit entry, and the entries decide the gate.

Map Every Chunk to a size-limit Entry

size-limit reads a config file that lists named entries; each entry globs one or more built files, and each declares a byte ceiling. It runs the same gzip (or Brotli) pass a CDN would, so the number it reports is transfer size, not disk size — which is the number that actually costs your users download time. The config below gives each route chunk its own line, plus a line for the entry bundle and one for the React vendor chunk. Because the file names carry a stable prefix, the glob matches across rebuilds even though the content hash changes.

[
  {
    "name": "entry (shell + router)",
    "path": "dist/assets/index-*.js",
    "limit": "60 kB",
    "gzip": true
  },
  {
    "name": "react-vendor (shared)",
    "path": "dist/assets/react-vendor-*.js",
    "limit": "45 kB",
    "gzip": true
  },
  {
    "name": "route: home",
    "path": "dist/assets/route-home-*.js",
    "limit": "45 kB",
    "gzip": true
  },
  {
    "name": "route: dashboard",
    "path": "dist/assets/route-dashboard-*.js",
    "limit": "90 kB",
    "gzip": true
  },
  {
    "name": "route: reports",
    "path": "dist/assets/route-reports-*.js",
    "limit": "120 kB",
    "gzip": true
  },
  {
    "name": "route: settings",
    "path": "dist/assets/route-settings-*.js",
    "limit": "55 kB",
    "gzip": true
  },
  {
    "name": "route: admin",
    "path": "dist/assets/route-admin-*.js",
    "limit": "70 kB",
    "gzip": true
  }
]

The point of one entry per route is isolation. If the reporting team adds a heavier charting library, only the route: reports entry turns red. The home route's budget is untouched, so the gate reports exactly which route regressed and by how many kilobytes, rather than a single global number that could be blamed on anyone.

Set a Per-Route Byte Budget Table

Budgets are only credible when they are grounded in a device and network the user actually has. The table below is device-contextual: it assumes a mid-range Android phone (roughly a Moto G-class device) on a Slow 4G / Fast 3G connection, which is the profile most likely to define your worst-tolerable experience. The transfer column is gzipped JavaScript for that route's own chunk only — it excludes the shared vendor chunk and the entry shell, which are budgeted on their own lines. The "parse/execute at P90" column is the time that many bytes take to parse and run on that class of device, and it is why byte budgets exist: bytes turn into main-thread time.

At P75 on that mid-range mobile profile, a route whose own chunk stays under roughly 90 kB gzipped keeps its incremental transition well inside a soft-navigation budget; push past 120 kB and the parse cost alone starts eating into the interaction budget on the slowest tenth of devices. Treat the numbers below as a starting allocation to calibrate against field data, not universal law.

Route Own chunk (gzip) Transfer at P75, Fast 3G mobile Parse/execute at P90, mid-range mobile Rationale
Home / index 45 kB ~0.9 s ~120 ms First view; must be lightest
Settings 55 kB ~1.1 s ~150 ms Forms, light validation
Admin 70 kB ~1.4 s ~190 ms Tables, gated to few users
Dashboard 90 kB ~1.8 s ~250 ms Widgets, moderate charting
Reports 120 kB ~2.4 s ~330 ms Heavy charting, exports
Per-route gzipped budgets Bar chart comparing gzipped kilobyte budgets across home, settings, admin, dashboard and reports routes against a soft ceiling line. Per-route own-chunk budget (gzip kB) soft ceiling 130 kB 45 Home 55 Settings 70 Admin 90 Dashboard 120 Reports Budgets exclude the shared react-vendor and entry-shell chunks, which are capped separately.
The reports route earns the largest budget for its charting weight, but still sits under the shared soft ceiling.

Wire It Into the CI Gate

The gate is a single job that builds the app, runs size-limit, and fails the pull request when any entry exceeds its ceiling. size-limit returns a non-zero exit code when a limit is breached, so no extra scripting is needed to fail the step. Add a --json run when you want to publish the per-route numbers as a comment or artifact. This job pairs naturally with the pull-request Lighthouse flow described in Running Lighthouse CI on Every Pull Request.

name: bundle-budgets
on:
  pull_request:
    branches: [main]
jobs:
  size-limit:
    runs-on: ubuntu-latest
    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 size-limit
      - name: Publish per-route report
        if: always()
        run: npx size-limit --json > size-report.json
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: size-report
          path: size-report.json

Run the same npx size-limit command locally as a pre-push check so contributors see a red route before CI does. Keeping the developer feedback loop identical to the gate is what stops "it passed on my machine" arguments: the byte count is deterministic for a given build.

Troubleshoot Shared-Vendor Double Counting

The most common failure when route budgets look inflated is double counting shared vendor code. If React, the router, and a charting library are inlined into every route chunk instead of being hoisted into one shared chunk, each route pays for the same 45 kB of React again. Your route: reports entry then reports 165 kB when the route's own code is only 120 kB, and you will be tempted to raise the wrong budget. The fix is a manualChunks rule (shown earlier) that pins node_modules/react and other shared dependencies into a single react-vendor chunk that every route imports but only one budget line counts.

Verify the split by checking that the shared library appears in exactly one output file. A quick check against the build output confirms there is a single vendor chunk and that a route chunk does not re-bundle it.

# Expect exactly one react-vendor chunk, and no react runtime inside a route chunk.
grep -l "react-dom" dist/assets/react-vendor-*.js
grep -L "react-dom" dist/assets/route-reports-*.js
Shared vendor counted once Two route chunks both import one shared react-vendor chunk, which is budgeted on a single line instead of once per route. Shared bytes belong to one budget line route-reports 120 kB own route-dashboard 90 kB own react-vendor 45 kB, counted once Without hoisting, each route re-bundles react and its budget appears 45 kB too high.
Hoist shared dependencies into one vendor chunk so its bytes are budgeted once, not once per route.

A second subtle case is over-eager prefetching. React Router can prefetch a route's chunk on link hover or viewport entry, which is good for perceived speed but means the bytes still ship. Prefetching does not change the transfer budget for that route — it only changes when the transfer happens — so keep the per-route ceiling the same and let the prefetch strategy be a separate decision measured against your soft-navigation interaction budget rather than the byte budget.

Frequently Asked Questions

Should the shared vendor chunk count against each route's budget?

No. Hoist React, the router, and other cross-route dependencies into a single vendor chunk and give it one budget line. Counting it inside every route chunk inflates each route by the same amount and hides which route actually regressed.

Why measure gzipped bytes instead of the raw file size on disk?

Users download the compressed transfer, so gzipped (or Brotli) size is what actually costs load time. size-limit runs the same compression pass a CDN would and reports transfer size, which is the number your budget should track.

How do I keep the glob matching when the content hash changes every build?

Give each chunk a stable human-readable prefix via chunkFileNames and manualChunks, for example route-reports-[hash].js. The size-limit path globs route-reports-*.js, so it matches across rebuilds even though the hash portion changes.

Does route prefetching change a route's byte budget?

No. Prefetching changes when a chunk downloads, not how many bytes ship. Keep the per-route transfer budget the same and treat prefetch strategy as a separate call, judged against your soft-navigation interaction budget rather than the byte ceiling.

What device and network should the per-route numbers assume?

Anchor budgets to a mid-range Android phone on Slow 4G or Fast 3G, because that profile defines your worst-tolerable experience. The sample table targets keeping each route's own chunk transfer under roughly 90 kB at P75, with heavier routes allowed up to about 120 kB before parse cost at P90 threatens the interaction budget.