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 fetched, 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 weeks later.
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 (brotli) passes as long as some other route shrank by the same amount. The aggregate is green; the field data for that one route is red. Per-chunk budgeting closes that gap by bounding each import() boundary individually, which is the same discipline applied per entrypoint in Enforcing Per-Route JavaScript Budgets — here we extend it down to the async boundaries that a single-page shell reveals on demand.
Why an Aggregate Budget Hides Chunk Regressions
Consider two lazy routes whose combined transfer stays flat across a release. The Dashboard route was refactored and shed 70 KB; the Reports route quietly gained an uncompressed date library and grew from 20 KB to 90 KB. A single resource-summary:script:size assertion sums both and reports no change, so the gate passes. Yet Reports now ships a chunk that parses and compiles in roughly 210 ms on a mid-range Android device (Moto G-class, 4x CPU throttling), pushing that route's INP past the 200 ms P75 target for slow-phone users on Fast 3G. The diagram below shows how the aggregate stays inside its ceiling while one chunk sails past its per-chunk line.
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 (Moto G-class) under 4x CPU throttling. Pair every transfer limit with the execution budget — transfer size alone hides parse and compile cost on slow CPUs, and the interaction it blocks is measured at P75, not the median.
| 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 P75 budget on mid-range mobile |
| Shared async vendor | ≤ 60 KB | ~140 ms | Cached across routes; amortized, so a larger allowance is justified |
| Per-component lazy widget | ≤ 20 KB | ~50 ms | Below-the-fold modules loaded on intersection, off the interaction path |
| Concurrent async requests | ≤ 3 | — | Avoids HTTP/2 head-of-line stalls and connection contention on Fast 3G |
A route that needs more than 50 KB of its own code (brotli) is a signal to split again — extract the heavy dependency into a separately loaded widget rather than widening the route budget. On a fast desktop connection these ceilings feel generous, but the numbers are pinned to the P75 mid-range-mobile experience because that is where the interaction budget is tightest and where field data first goes red.
The Anatomy of a Lazy Chunk's Cost
It helps to trace what actually happens when a user clicks a link that triggers a dynamic import(). The bytes leave the network as compressed transfer, but every downstream stage runs on the main thread and competes with the click the user just made. Transfer size sets the download time; the decompressed size sets parse and compile time; and the module's top-level code plus the component render set execution time. The diagram makes the pipeline explicit so you can see why a 90 KB brotli chunk is not "just 70 KB more download."
Diagnostic Steps
-
Attribute bytes to modules with source-map-explorer so you know what is inside each lazy chunk before you set a ceiling.
npx source-map-explorer 'dist/assets/route-*.js' --html report.htmlExpected output: an HTML treemap; look for a single dependency consuming more than 30% of a route chunk — that is your split candidate.
-
List chunk sizes from the build manifest to find the offending boundary.
npx webpack-bundle-analyzer dist/stats.json --mode static --no-openExpected output: a static treemap showing each async chunk with gzip and brotli sizes; sort by size and confirm which
import()produced the largest. -
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 where a wildcard
export *is dragging in siblings you never call.
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 and Rollup, name chunks through the output config so the same route-* glob applies and third-party code lands in a shared, cacheable vendor chunk:
// 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";
},
},
},
},
});
Below-the-fold widgets should load on intersection rather than on route entry, which keeps the entry chunk lean and pushes the widget's parse cost out of the navigation window entirely:
// lazyWidget.js — defer a heavy widget until it scrolls near the viewport
import { lazy, Suspense, useEffect, useRef, useState } from "react";
const Chart = lazy(() =>
import(/* webpackChunkName: "widget-chart" */ "./widgets/Chart")
);
export function DeferredChart() {
const ref = useRef(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) setVisible(true);
},
{ rootMargin: "200px" }
);
if (ref.current) io.observe(ref.current);
return () => io.disconnect();
}, []);
return (
<div ref={ref}>
{visible ? (
<Suspense fallback={<div>Loading chart</div>}>
<Chart />
</Suspense>
) : null}
</div>
);
}
Deciding When to Split Again
Not every large chunk should be split — over-splitting creates a request waterfall that costs more round trips than it saves in bytes, which hurts more than it helps on high-latency Fast 3G links. Use a fixed rule instead of intuition: if a route entry chunk exceeds its 50 KB brotli ceiling and a single dependency owns more than 30% of it, extract that dependency behind its own boundary; otherwise leave the chunk whole and tighten the code inside it. The decision tree captures the path.
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, so a Reports regression can no longer hide behind a Dashboard win:
{
"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 }]
}
}
}
}
Wire both into a single job so one red check blocks the merge. The per-chunk bundlesize step catches the localized regression; the Lighthouse aggregate catches slow, broad drift that no single chunk trips:
# .github/workflows/bundle-budget.yml
name: bundle-budget
on: pull_request
jobs:
budget:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
- run: npm run build
- run: npx bundlesize
- run: npx @lhci/cli autorun
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; the same soft-navigation measurement discipline is covered for client rendered shells in Budgeting Soft-Navigation INP in SPAs. If you maintain distinct budgets per framework router, the route-level chunk mapping in Route-Level Bundle Budgets for React Router pairs directly with these globs.
Edge Cases Worth Budgeting For
Preloaded chunks still cost execution. Adding <link rel="modulepreload"> or a webpack prefetch hint moves the download earlier but does not remove the parse and compile cost from the navigation; keep the per-chunk ceiling even when you preload, because the interaction budget is spent on the main thread regardless of when the bytes arrived.
Shared vendor chunks amortize only if they are actually shared. A vendor-async chunk earns its larger 60 KB allowance because it is cached across routes; if only one route imports it, it is really a route chunk in disguise and should be held to the 50 KB entry ceiling.
Request waterfalls beat byte savings on slow links. Splitting a 55 KB chunk into three 18 KB chunks looks like a win on the byte report, but three serialized round trips on a Fast 3G connection (roughly 300 ms RTT) can cost more wall-clock time than the single larger download. Cap concurrent async requests at three and prefer one right-sized chunk over many tiny ones. When you are unsure whether to target the median or the tail of your device population, the trade-offs in Choosing Between P75 and P90 Budget Targets apply to chunk ceilings exactly as they do to metrics.
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 200 ms INP P75 budget on mid-range mobile.
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.
Should I budget transfer size or decompressed size?
Gate on the compressed transfer size in CI because that is what a tool like bundlesize measures deterministically, but reason about the decompressed weight when you set the ceiling. Parse and compile time scales with the raw bytes the browser unpacks, so a 50 KB brotli chunk that expands to 220 KB of JavaScript costs roughly 120 ms of main-thread work on a mid-range Android device under 4x CPU throttling.
Does preloading a chunk let me raise its budget?
No. A modulepreload or prefetch hint moves the download earlier so the bytes are on disk before the click, but the parse, compile, and execute stages still run on the main thread during the navigation and still block Interaction to Next Paint. Keep the per-chunk ceiling unchanged; preloading improves download timing, not execution cost.
How many concurrent lazy chunks should a route load?
Cap it at three. Beyond that you hit HTTP/2 head-of-line stalls and connection contention, and on a Fast 3G link with roughly 300 ms round-trip time the serialized requests cost more wall-clock time than one right-sized chunk would. If a route needs four or more async requests, consolidate the smallest widgets into a shared chunk.