Budgeting for Dynamic Import Code Splitting

Dynamic import() moves bytes off the critical path, but it moves the cost rather than removing it: a lazy chunk still gets parsed, compiled, and executed on the main thread the moment a user navigates to its route, and an uncapped lazy chunk degrades Interaction to Next Paint exactly when the user is most engaged. This guide, part of the JavaScript Bundle Size Limits reference, sets exact per-chunk ceilings for code-split modules and shows how to assert them in CI so async growth is caught at review time instead of in field telemetry.

The failure mode is specific: a global script budget aggregates every chunk into one number, so a route that grows from 20 KB to 90 KB passes as long as some other route shrank. Per-chunk budgeting closes that gap by bounding each import() boundary individually.

Chunk Size Breakdown

The table below partitions a route transition by chunk role and gives a brotli transfer ceiling plus the execution cost it implies on a mid-range Android device under 4x CPU throttling. Pair every transfer limit with the execution budget — transfer size alone hides parse/compile cost on slow CPUs.

Chunk role Transfer ceiling (brotli) Parse + compile @ 4x CPU Rationale
Route entry chunk ≤ 50 KB ~120 ms Keeps a navigation under the 200 ms INP budget
Shared async vendor ≤ 60 KB ~140 ms Cached across routes; amortized, so a larger allowance
Per-component lazy widget ≤ 20 KB ~50 ms Below-the-fold modules loaded on intersection
Concurrent async requests ≤ 3 Avoids HTTP/2 head-of-line and connection contention

A route that needs more than 50 KB of its own code is a signal to split again — extract the heavy dependency into a separately loaded widget rather than widening the route budget.

Diagnostic Steps

  1. Attribute bytes to modules with source-map-explorer so you know what is inside each lazy chunk.

    npx source-map-explorer 'dist/assets/route-*.js' --html report.html

    Expected output: an HTML treemap; look for a single dependency consuming more than 30% of a route chunk — that is your split candidate.

  2. List chunk sizes from the build manifest to find the offending boundary.

    npx webpack-bundle-analyzer dist/stats.json --mode static --no-open

    Expected output: a static treemap showing each async chunk with gzip/brotli sizes; sort by size and confirm which import() produced the largest.

  3. Check for unused bytes in Chrome DevTools → Coverage tab: reload the route, filter to the lazy chunk, and flag any module over 30% unused — that points to a tree-shaking or barrel-import problem.

Implementation

Use magic comments to name and group chunks so their globs stay stable for CI, and split at the route boundary so each navigation pulls exactly one entry chunk.

// router.js — route-level lazy loading with named chunks
import { lazy } from "react";

const Dashboard = lazy(() =>
  import(/* webpackChunkName: "route-dashboard" */ "./routes/Dashboard")
);
const Reports = lazy(() =>
  import(/* webpackChunkName: "route-reports" */ "./routes/Reports")
);

export const routes = [
  { path: "/dashboard", element: Dashboard },
  { path: "/reports", element: Reports },
];

For Vite/Rollup, name chunks through the output config so the same route-* glob applies:

// vite.config.js
import { defineConfig } from "vite";

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        chunkFileNames: "assets/[name]-[hash].js",
        manualChunks(id) {
          if (id.includes("node_modules")) return "vendor-async";
        },
      },
    },
  },
});

CI Gating Assertion

Gate the named chunks with bundlesize, which matches per-path globs and fails the build on breach. This config bounds each role independently:

{
  "bundlesize": [
    { "path": "dist/assets/route-*.js", "maxSize": "50 kB", "compression": "brotli" },
    { "path": "dist/assets/vendor-async-*.js", "maxSize": "60 kB", "compression": "brotli" },
    { "path": "dist/assets/widget-*.js", "maxSize": "20 kB", "compression": "brotli" }
  ]
}

If Lighthouse CI already runs in your pipeline, assert the aggregate transfer ceiling there too so total async weight cannot drift even when individual chunks pass:

{
  "ci": {
    "assert": {
      "assertions": {
        "resource-summary:script:size": ["error", { "maxNumericValue": 300000 }],
        "total-byte-weight": ["warn", { "maxNumericValue": 1600000 }]
      }
    }
  }
}

Verification

After wiring the assertion, run the build and the gate locally before pushing:

npm run build && npx bundlesize

A passing run prints one line per glob, for example PASS dist/assets/route-dashboard-a1b2.js: 47.2 kB <= 50 kB (brotli). Force a failure once by importing a large dependency into a route to confirm the gate exits non-zero and blocks the merge — a budget you have never seen fail is a budget you cannot trust. Then segment INP by route in your field data to confirm the byte ceiling actually holds the interaction budget on real devices.

Frequently Asked Questions

Why budget lazy chunks if they are off the critical path?

Lazy chunks are off the initial load path but directly on the navigation path. When a user clicks through to a route, that chunk is parsed and executed on the main thread immediately, so an uncapped lazy chunk shows up as poor Interaction to Next Paint during navigation. Bounding each import() boundary keeps every transition inside the interaction budget.

How do I keep CI globs stable across builds?

Name chunks explicitly — /* webpackChunkName: "route-reports" */ in webpack or chunkFileNames: "assets/[name]-[hash].js" in Rollup — so the role prefix is stable and only the content hash changes. Match on the prefix glob (route-*.js) in size-limit or bundlesize, not the hash.