Bundle Optimization: Splitting Chunks Can Make First Paint Slower—The Real Lever Is Invalidation
Bundle Optimization
I. Technical Difficulties
The end of Day29 split the work clearly: that episode handled scheduling within a single thread; unpacking is left to Day30 (that's how bytes get to the browser). This episode answers it.
But the name "bundle optimization" is the first trap. It reduces a three-way joint optimization to a 'make the number smaller' action. Here is the conclusion:
Bundle optimization isn't about making the bundle smaller; it's finding balance among three conflicting budgets: initial bytes, request shape, and invalidation surface. Two of these live in the build config; the third lives in the filename's square brackets—and almost no team looks at the third, even though it carries the biggest lever.
This episode demolishes three pieces of 'common sense', all of which I wrote into scripts as truth and then had my own assertions overturn:
'Finer splitting means faster first paint' is wrong. Splitting doesn't change initial bytes. The same 960KB entry, split into 16 slices, adds +60.27ms; 32 slices add another +61.16ms; gzip size rises from 317.38KB to 346.22KB to 360.67KB. What actually cuts first paint from 2770.6ms to 1278.21ms is the exclusion of the other 1230KB from the critical path, not the reshaping of the 960KB (
chunk-budget.mjsPART 1).'Route splitting always brings gains' is not necessarily true. If shared dependencies aren't hoisted into separate chunks but are copied into each route chunk, three navigations transfer 671.9KB, which is 2.3% more than the unsplit single bundle's 657KB; time saved is only 1.9% (2718.72ms vs 2770.6ms). 100% of the gain from splitting comes from hoisting shared modules, 0% from the act of splitting routes (PART 2).
'Bundle optimization cares about a single visit' misses the biggest half. Adding up 20 revisit costs: the route-split +
[fullhash]option totals 24310.32ms, which is worse than the unsplit single bundle's 23876.62ms. Switching[fullhash]to[contenthash]and locking module ids to deterministic brings the same code to 8981.65ms (PART 4).
All numbers come from three zero-dependency Node scripts, totaling 66 invariant assertions; a failed assertion exits with code 1. Like Day25–Day29, these are deterministic simplification models—their value is comparing strategies in the same coordinate system, not predicting absolute milliseconds on a given machine. Parameters and each script's optimistic assumptions are detailed in the 'Environment' section at the end.
Boundaries stated: this episode covers how bytes are organized, requested, and cached. Images and fonts go to Day31, Worker/OffscreenCanvas to Day32, WASM to Day33, performance monitoring and RUM to Day34, and build tools themselves (vite/webpack/turbopack architecture differences) to Day35—that one is about 'who bundles', this one is about 'what the bundle should look like'. Mixing them usually results in neither being explained well.
Difficulty 1: Splitting Doesn't Change Initial Bytes, It Changes Request Shape
First, the cleanest table. A 2190KB (minified) app; the first screen needs only 960KB:
| Scheme | Initial KB | gzip KB | Layers | h2 FCP ms | h1 FCP ms |
|---|---|---|---|---|---|
| A single bundle | 2190 | 657 | L0:1 | 2770.6 | 2770.6 |
| B route-split, entry lazy | 960 | 317.38 | L0:3 + L1:1 | 1330.11 | 1330.11 |
| C route-split + modulepreload entry | 960 | 317.38 | L0:4 | 1278.21 | 1278.21 |
| D entry 960KB split into 16 | 960 | 346.22 | L0:16 | 1338.48 | 1448.48 |
| E entry 960KB split into 32 | 960 | 360.67 | L0:32 | 1399.64 | 1674.64 |
Deconstructing the increments:
| Comparison | Δ ms |
|---|---|
| A→C: move 1230KB out of first screen (exclusion) | −1492.39 |
| B→C: add modulepreload to entry routes (waterfall flattening) | −51.90 |
| C→D: split same 960KB into 16 (reshaping) | +60.27 |
| D→E: split further to 32 | +61.16 |
Exclusion saves 1492ms; reshaping costs 121ms. That's why 'make the bundle smaller' is a dangerous phrase—it focuses all attention on reshaping.
Why does finer splitting always lose? Three bills, each calculable:
- Compression ratio loss. gzip compresses worse for smaller inputs (the model uses a size-decaying curve): 960KB compresses to 0.301, 60KB slices to 0.361, 30KB slices to 0.376. The same 960KB, as 4 slices, totals 317.38KB gzip; as 32 slices, 360.67KB—+43.29KB, +13.6%.
- Per-request fixed overhead. h2 reuses connections, but header processing, stream scheduling, and cache lookups still bill per request. Going from 4 to 32 slices adds 28 requests.
- Compilation context overhead. Each chunk is a separate script; V8 builds a separate parse/compile context. This is small but additive.
Then the easiest-to-overlook bill: HTTP/1.1 queueing. In the last column, D (16 slices) queues 2 rounds at +110ms; E (32 slices) queues 5 rounds at +275ms—19.6%. So the 'fine splitting costs ~5%' conclusion on h2 becomes 4x worse on a non-concurrent link. That's why 'we're on h2' must be verified, not assumed.
Difficulty 2: Route Splitting's Gains Come Entirely from Shared Hoisting, Not from Splitting Routes
Separating 'splitting' from 'hoisting' is the whole point of this section. Same app, user visits dash → order → report sequentially:
| Scheme | 3 routes cumulative KB | cumulative gzip KB | time ms |
|---|---|---|---|
| A single bundle | 2190 | 657 | 2770.6 |
| B split + shared hoisted | 1600 | 525.19 | 2084.45 |
| C split (no hoisting) | 2120 | 671.9 | 2718.72 |
Per-hop:
| Scheme | order hop | report hop |
|---|---|---|
| B after hoist | 109.96 KB | 97.84 KB |
| C not hoisted | 186.66 KB | 175.05 KB |
Scheme C is the classic anti-pattern nearly every project hits once:
// Anti-pattern: each route chunk copies the shared modules it uses
const routes = {
dash: () => import('./pages/dash'),
order: () => import('./pages/order'),
report: () => import('./pages/report'),
};
// pages/order imports { Table } from '@/components/table'
// -> Table is bundled into route-order, also into route-dash and route-report
Table, Form, DateUtil get copied into 3 route chunks. First screen looks identical (960KB), but every cross-route navigation re-downloads that 260KB. Over three navigations, cumulative transfer is 671.9KB > 657KB—more expensive than no splitting.
So the correct question for 'should I split' isn't 'how many routes did I split', but:
Does any module appear in ≥2 chunks that the same user will visit?
If yes, hoist first, tune granularity later. This question is CI-able (the duplication check in 2.1's budget-gate.mjs), and it's far more sensitive than 'total bundle size' because duplicating shared modules barely moves total size—only navigation cost changes.
A corollary: why splitChunks.minChunks and cacheGroups exist? Their only reason is to 'hoist modules used by multiple chunks.' If your config has no rule expressing 'who is duplicated,' your splitting is probably just reshaping.
Difficulty 3: The Third Budget—Invalidation Surface—Nobody Watches, Biggest Lever
Now extend the timeline from 'one visit' to 'twenty versions × twenty visits.'
A 960KB first screen, 192 modules (5KB each). Simulate 20 releases, each averaging 11.4 changed modules, with 70% concentrated in a hot directory. Scan granularity from 1 chunk to module-per-chunk:
| Granularity | per chunk | FCP ms | invalid chunks | invalid KB | invalid gzip KB | revisit ms | total V=5 | total V=20 |
|---|---|---|---|---|---|---|---|---|
| 1 | 960KB | 1246.08 | 1 | 960 | 288.85 | 1191.08 | 6010.4 | 23876.62 |
| 2 | 480KB | 1260.74 | 1.9 | 912 | 288.14 | 1149.71 | 5859.57 | 23105.2 |
| 3 | 320KB | 1270.6 | 2.55 | 816 | 264.99 | 1049.59 | 5468.97 | 21212.85 |
| 4 | 240KB | 1278.5 | 2.8 | 672 | 222.42 | 893.14 | 4851.06 | 18248.16 |
| 6 | 160KB | 1291.46 | 3.85 | 616 | 209.31 | 835.05 | 4631.66 | 17157.42 |
| 8 | 120KB | 1302.46 | 4.2 | 504 | 174.4 | 711.92 | 4150.15 | 14829.01 |
| 12 | 80KB | 1321.62 | 5.4 | 432 | 153.29 | 633.83 | 3856.96 | 13364.48 |
| 16 | 60KB | 1338.82 | 5.95 | 357 | 128.91 | 550.58 | 3541.13 | 11799.8 |
| 24 | 40KB | 1370.38 | 7.1 | 284 | 105.05 | 469.44 | 3248.14 | 10289.75 |
| 32 | 30KB | 1399.98 | 7.9 | 237 | 89.15 | 416.77 | 3067.05 | 9318.55 |
| 48 | 20KB | 1456.34 | 8.85 | 177 | 68.14 | 348.96 | 2852.18 | 8086.56 |
| 64 | 15KB | 1510.74 | 10 | 150 | 58.68 | 318.44 | 2784.51 | 7561.16 |
| 96 | 10KB | 1616.7 | 10.4 | 104 | 41.6 | 265.68 | 2679.42 | 6664.62 |
| 192 | 5KB | 1925.86 | 11.4 | 57 | 23.66 | 211.38 | 2771.36 | 5942.01 |
Three conclusions live in this table:
First, FCP is nearly flat. From 1 to 192 chunks, FCP goes 1246.08ms to 1925.86ms—a +679.78ms (+54.6%) jump, but that's an extreme comparison: 1→16 chunks only add 92.74ms (+7.4%), 16→64 add 171.92ms. In the realistic range (4–32 chunks), the 'finer splitting slows FCP' penalty is under 10%.
Second, revisit cost drops almost linearly. From 1191.08ms/visit to 211.38ms/visit—−82.3%. Because invalid bytes drop from 960KB to 57KB, and invalid bytes are what returning users re-download and re-parse each time. Invalid chunk count rises from 1 to 11.4, exactly equal to the average changed modules per release—when you split to module-per-chunk, invalidation equals change—a mathematical lower bound, impossible to optimize further.
Third, the optimum depends on how many times the user returns.
- V=1 (one visit): optimal is 1 chunk. Single-visit users don't pay invalidation costs; they only pay for first load.
- V=5: optimal is 96 chunks.
- V=20: optimal is 192 chunks (module-per-chunk).
This isn't model trickery; it's the actual structure of the problem:
The optimal granularity of bundle optimization = f(user revisit count V × release cadence k). It's not a property of your code—it's a property of your users and your release process.
A product that ships 5 versions a day with 80% returning DAU, and a landing page that ships quarterly with mostly one-time visitors, have opposite optimal splitting strategies. That's why 'best practices' lists can never answer this; they don't know V and k.
Marginal returns also decay:
| Granularity change | Savings over 19 revisits | Extra FCP | Net benefit |
|---|---|---|---|
| 3→4 chunks | 2972.59 | 7.9 | 2964.69 |
| 4→6 | 1103.7 | 12.96 | 1090.74 |
| 16→24 | 1541.61 | 31.56 | 1510.05 |
| 48→64 | 579.79 | 54.4 | 525.39 |
| 96→192 | 1031.77 | 309.16 | 722.61 |
The first increment nets 4.1x the last. So in engineering terms, 'split to 16–32 chunks, keep invalidation explainable' is usually the cost-benefit inflection—not because the model says 192 is unprofitable, but because each chunk beyond 32 costs you a build artifact, a CDN cache entry, release process complexity, and debugging mental overhead—none of which show up in the milliseconds.
Difficulty 4: The Lever of Hash and ID Is Greater Than Granularity
Now the same code, four 'filename strategies.' Metric: total over 20 revisits (one FCP + 19 times re-download + re-parse invalid parts; valid parts count at 0.15x via V8 code cache).
| Filename strategy | Invalid KB/version | Invalid gzip KB | Revisit ms | Total V=20 |
|---|---|---|---|---|
single + [fullhash] |
960 | 288.85 | 1191.08 | 23876.62 |
route + [fullhash] |
960 | 317.38 | 1213.91 | 24310.32 |
route + [contenthash] + unstable id |
780 | 256.68 | 1012.35 | 20480.65 |
route + [contenthash] + deterministic id |
236 | 78.17 | 407.13 | 8981.65 |
Two nails:
Nail 1: [fullhash] makes splitting negative-return. The second row (24310.32ms) is worse than the single bundle (23876.62ms). Because [fullhash] (webpack 5's new name for [hash]) is a hash of the entire build: change one character, all chunk filenames change, everything invalidates. You pay for 4 extra requests on FCP and get zero cache benefit. This is the worst possible end-to-end config: get it right and it's 8981.65, get it wrong and it's 24310.32—2.7x difference with not a single line of code changed.
Nail 2: module id stability alone has 2.3x leverage. Row 3, just 'unstable id,' inflates invalidation from 236KB to 780KB (8981.65 → 20480.65). Reason: when module ids are assigned by numeric order (moduleIds: 'natural' / 'named' non-deterministic variants), timestamps, or path sort, adding/removing any module shifts every subsequent id, so all referencing chunks change content and hashes.
Together: before agonizing over 'how fine to split,' confirm that splitting actually saves transfer. Filename and id stability are the prerequisite for splitting gains, not a bonus.
Difficulty 5: The Tree-Shaking Switch Isn't in the Bundler—It's in package.json
The biggest misconception about tree shaking is thinking it's a bundler capability; it's actually whether the module author has provided the 'can delete' proof.
I implemented a 200-line reachability-based elimination engine (treeshake-model.mjs PART A: work queue + name resolution + module settlement), then ran four versions of the same icon library (120 icons, 1.6KB each), business uses only 1:
| Version | Modules bundled | Bytes kept | vs deep import |
|---|---|---|---|
deep import icons/Icon7 (bypasses barrel) |
3 | 4.2 KB | 1x |
barrel import + sideEffects: false |
4 | 4.3 KB | 1.024x |
barrel import + no sideEffects declared |
123 | 194.7 KB | 46.357x |
barrel import + export default { ... } namespace object |
123 | 195.1 KB | 46.452x |
One package.json field: 190.4KB. At the model's 1ms/KB parse rate, FCP pays an extra 190.4ms—while the import line in the code didn't change a single character.
Four mechanisms worth writing down:
- Barrel files aren't the crime; undeclared
sideEffectsis. The sameimport { Icon7 } from 'icons'withsideEffects: falsekeeps 4.3KB (only 0.1KB overhead vs deep import). So the military rule 'ban barrel files' is wrong; the right rule is 'packages with barrel files must declaresideEffects.' export *is catastrophic when undeclared. The bundler can't prove the 120 forwarded modules have no top-level side effects, nor which name resolves to which, so it evaluates all of them—194.7KB.export default { ... }kills tree shaking. Namespace members are runtime property accesses; static analysis can't see you only useIcon7, so 195.1KB. This is the most expensive price you can pay for import convenience.- Dynamic
import()'s keys must be statically enumerable.import('./icons/' + name)or switch branches with unknown values force the bundler to keep all branches (webpack's 'Critical dependency: the request of a dependency is an expression' warning is exactly this).
And the other side of sideEffects: false—it can delete useful code. treeshake-model.mjs PART C is an executable proof:
sideEffects declaration |
Top-level side effects | Runtime result |
|---|---|---|
| correct (default unknown) | kept | ok |
wrongly false |
removed | return broken |
Notice the result is broken, not an error—the most insidious form of this bug: a polyfill module's top-level globalThis.__SUPPORTS_BIGINT = ... gets removed as dead code, and the checkBigInt() that depends on it doesn't throw—it just silently returns a wrong value.
So: sideEffects: false is not a performance switch; it's a factual declaration. It saves bytes, and the cost can be all 'registration' code—polyfills, global style injection, decorator registration, customElements.define, dayjs.extend—any module that 'takes effect on import.' Before writing this field you must confirm: does every module in this package truly have no observable top-level behavior?
Difficulty 6: Discovery Depth = RTT, the Only Linear-in-Depth Cost
Back to PART 1's second row: B (entry route lazy) is 51.9ms slower than C (entry route modulepreload). Same bytes, same chunk count; the only difference is whether a chunk is declared in the entry chunk's static import list or discovered only after the entry chunk parses and executes to the import() call.
The model charges 'one extra RTT' for this, and RTT is 55ms. This magnitude deserves emphasis because it's the only cost that accumulates linearly with depth:
- Static
importis flattened by the bundler into the entry chunk's import list; all static chunks are discovered at layer 0, the browser issues them in parallel—depth 1. - Dynamic
import()chunks are discovered at layer 1 because the entry must download, parse, and execute to that line. - If
route-orderhas anotherimport('./order/detail'),detailis at layer 2. - If
detailhas a lazily-loaded chart library, that's layer 3.
Three levels = three RTTs = 165ms, pure waiting—no compression, no CDN can recover it. That's why modulepreload / webpackPreload / vite's build.modulePreload mechanisms aren't about 'starting the download earlier'—they're about flattening depth back to layer 0.
A judgement you can apply today:
// Entry route: don't lazy; static import + let bundler preload
// ❌ adds one RTT to FCP
const Dash = () => import('./pages/dash');
// ✅ entry route goes to layer 0; other routes stay lazy
import Dash from './pages/dash';
'All routes must be lazy' is a misheard rule: the entry route is part of the first screen; lazying it out is voluntarily extending the critical path by one RTT. The correct pattern is 'entry route static import, other routes lazy, prefetch adjacent routes on transition.'
II. Complete Solution
The six difficulties compress into one execution order, and the order cannot be swapped:
First exclude, then hoist, then lock the invalidation surface, and only then tune granularity.
Reason: granularity is the only knob whose wrong setting yields negative returns, and when it goes first you have neither a baseline nor a guardrail. Each of the five steps below has something runnable.
2.1 Step 1: Turn the 'Three Tables' into One CI Command
Most teams measure 'total bundle size.' That number has three problems: it counts never-fetched chunks into FCP; it can't see request count; it's completely blind to invalidation surface.
So first, change the metric to four numbers. day30-assets/budget-gate.mjs is exactly that—zero-dependency, reads a chunk manifest, outputs four checks:
$ node budget-gate.mjs stats.sample.json
budget-gate: entry = main, initial closure = 5 chunks
main, route-dash, shared, vendor, runtime
[PASS] initial bytes 974KB (limit 1000KB)
[PASS] initial gzip 324KB (limit 330KB)
[PASS] initial request count 5 chunks (limit 6)
[PASS] per-module invalidation 340KB (limit 360KB)
[PASS] business module duplication 0 (limit 0)
[PASS] duplication bytes 0KB (limit 0KB)
Largest invalidation module: src/pages/order/index.tsx -> [route-order]
Result: all pass
Run the same script on the 'naive route split' sample, and it immediately fails three checks:
$ node budget-gate.mjs stats.bad.json
[PASS] initial bytes 974KB (limit 1000KB)
[PASS] initial gzip 317KB (limit 330KB)
[PASS] initial request count 4 chunks (limit 6)
[FAIL] per-module invalidation 1628KB (limit 360KB) over +1268KB
[FAIL] business module duplication 3 (limit 0) over +3
[FAIL] duplication bytes 436KB (limit 0KB) over +436KB
Business modules copied to multiple chunks:
- src/components/table.tsx x3 (+240KB) [route-dash, route-order, route-report]
- src/components/form.tsx x3 (+180KB) [route-dash, route-order, route-report]
- src/utils/date.ts x3 (+16KB) [route-dash, route-order, route-report]
Notice the second sample's initial bytes and gzip both PASS—its FCP bytes are almost identical (974KB vs 974KB). If you only look at bundle size, you'd think they're equivalent. But its invalidation surface is 1628KB and duplication 436KB. That's exactly where 'total size' misleads: when shared modules are duplicated, total size barely moves; only navigation cost and invalidation change.
The four metrics' algorithms (all in the script):
// 1) Initial closure: all chunks reachable from entry via STATIC imports
function initialClosure(entryName) {
const seen = new Set(); const stack = [entryName];
while (stack.length) {
const name = stack.pop();
if (seen.has(name) || !chunks.has(name)) continue;
seen.add(name);
const c = chunks.get(name);
if (c.dynamic) continue; // dynamic chunks don't belong to initial closure
for (const dep of c.staticImports) stack.push(dep);
}
return [...seen];
}
// 2) Invalidation (worst case): the change lands on the business module with the largest coverage
// if a module is in k chunks, its change invalidates all k chunks
for (const [mod, owners] of moduleToChunks) {
if (mod.startsWith('node_modules/')) continue;
const kb = owners.reduce((s, n) => s + chunks.get(n).kb, 0);
if (kb > worstChurn.kb) worstChurn = { module: mod, kb, owners };
}
// 3) Duplication: business module in >1 chunk is a defect (intentional third-party copies are separate)
// 4) Request count: number of chunks in initial closure
Plug this into CI; exit 1 is the red line. The duplication check is the cheapest guardrail—when it fires, your splitting is doing negative work.
2.2 Step 2: Exclude—Move Bytes Out of the First Screen
This is the only action that yields thousand-millisecond gains (PART 1: −1492.39ms). Three rules, by priority:
Rule 1: Entry route static import, other routes lazy.
// routes.tsx
import Dash from './pages/dash'; // ✅ entry route: static, layer 0
const Order = lazy(() => import('./pages/order')); // others: dynamic
const Report = lazy(() => import('./pages/report'));
// Dash needs no Suspense in the route table; it's synchronously available
Rule 2: Add modulepreload to every chunk in the entry closure. Modern bundlers generate it at build time (vite on by default, webpack via preload plugin or /* webpackPreload: */ magic comment). Handwritten:
<link rel="modulepreload" href="/assets/shared.a1b2c3.js" crossorigin>
<link rel="modulepreload" href="/assets/vendor.d4e5f6.js" crossorigin>
<link rel="modulepreload" href="/assets/route-dash.g7h8i9.js" crossorigin>
This buys 51.9ms (one RTT) for 3 lines of HTML.
Rule 3: Actually delete what you don't need—don't just lazy it. Lazy only moves it from the first screen to layer 2; if it's never used, remove it from the dependency tree (see 2.5 on tree shaking). 'Using lazy to hide an oversized dependency' is the most common fake optimization—it makes FCP look good, then charges the user full price on first navigation.
2.3 Step 3: Hoist Shared Modules—One Copy Per Reused Module
This step decides whether splitting is positive or negative (PART 2). webpack 5 config (version differences apply):
// webpack.config.js
module.exports = {
output: {
filename: 'assets/[name].[contenthash:8].js',
chunkFilename: 'assets/[name].[contenthash:8].js',
},
optimization: {
moduleIds: 'deterministic', // see 2.4: this line matters more than any chunk granularity
chunkIds: 'deterministic',
runtimeChunk: 'single', // extract webpack runtime so it doesn't drag business chunks
splitChunks: {
chunks: 'all', // key: not just async, also initial
minSize: 20000, // don't emit chunks under 20KB (compression loss eats gains)
minChunks: 2, // used by >=2 chunks → hoist. This IS 'shared hoisting'
cacheGroups: {
react: {
test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
name: 'vendor-react',
priority: 40, // higher priority matches first
chunks: 'all',
enforce: true,
},
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
priority: 20,
chunks: 'all',
minChunks: 2, // libs used by only one entry stay put; don't force-hoist
},
shared: {
test: /[\\/]src[\\/](components|utils|hooks)[\\/]/,
name: 'shared',
priority: 30,
chunks: 'all',
minChunks: 2,
},
},
},
},
};
The vite (Rollup) equivalent is manualChunks, more 'hand-written' and easy to miss shared hoisting:
// vite.config.ts
export default defineConfig({
build: {
modulePreload: { polyfill: true }, // auto-preload entry closure
cssCodeSplit: true,
rollupOptions: {
output: {
// function form: gets id, so can express 'who is reused'
manualChunks(id) {
if (id.includes('node_modules')) {
if (/node_modules\/(react|react-dom|scheduler)\//.test(id)) return 'vendor-react';
return 'vendor';
}
if (/src\/(components|utils|hooks)\//.test(id)) return 'shared';
// rest goes to Rollup's automatic route dynamic import splitting
},
// Rollup 4: merge tiny chunks back into parent to avoid dozens of fragments
experimentalMinChunkSize: 20_000,
},
},
},
});
Three 'why you must understand' points:
chunks: 'all'instead of default'async'. The default only applies to dynamic imports, so shared modules in the initial screen never get hoisted—duplication stays among those 3–4 chunks.minChunks: 2is the definition of 'hoisting.' Without it,cacheGroupsis just renaming directories.minSize: 20000is the price line for compression loss. PART 1 already showed: slicing 240KB→30KB raises the gzip ratio from 0.301 to 0.376. 'Split every component into its own chunk' always loses byte-wise.
2.4 Step 4: Lock the Invalidation Surface—Bigger Lever Than Granularity
PART 4's three numbers (24310.32 / 20480.65 / 8981.65) show this step's leverage is multiplicative. Four lines:
// 1) Use contenthash, never fullhash/hash
output.filename = 'assets/[name].[contenthash:8].js';
// 2) Lock module id / chunk id to deterministic (webpack 5 prod defaults to deterministic,
// but if anyone changes to 'natural'/'named' for debugging, or a plugin uses timestamps, the floor collapses)
optimization.moduleIds = 'deterministic';
optimization.chunkIds = 'deterministic';
// 3) Runtime as separate chunk: runtime holds the chunk map; it changes with business inevitably,
// but it shouldn't drag vendor down with it
optimization.runtimeChunk = 'single';
// 4) CSS also uses contenthash, and follows its component
new MiniCssExtractPlugin({ filename: 'assets/[name].[contenthash:8].css' });
One easy miss: CSS and JS split granularity must be consistent. If JS splits per route but CSS is one global giant file, then any component style change invalidates the entire CSS file, eating most of PART 4's gains. That's why cssCodeSplit exists.
Debugging mantra: two consecutive builds of the same code yield different hashes → some step introduced non-determinism (timestamp, random id, Date.now() written into a banner, namedChunks, or a plugin iterating an unsorted Set). Fixing this is 100x more worthwhile than tuning granularity.
2.5 Step 5: Tree Shaking—Fill in the 'Can Delete' Proof
PART 5's 46x gap all lands on declarations. Four things to do:
(1) For packages you publish, declare sideEffects. Precisely to directories, not a lazy false:
// package.json (component lib / utility lib inside a monorepo)
{
"sideEffects": [
"*.css",
"*.scss",
"./src/polyfills/**", // these 'take effect on import', can't delete
"./src/styles/global.ts" // same
]
}
sideEffects: false means 'no observable behavior in any module's top level in this package.' The symptom of getting it wrong is not an error—it's silent breakage (PART C's return broken).
(2) Barrel files are fine, but they must live in a package that declares sideEffects. PART B proved: barrel + sideEffects: false = 4.3KB, deep import = 4.2KB, difference 0.1KB. So 'ban barrel files' is wrong; the correct rule is:
// src/components/index.ts — the barrel itself is fine
export { Table } from './table';
export { Form } from './form';
Provided src/components's owning package.json declares sideEffects.
(3) Ban export default { ... } as an API surface. 195.1KB is its price. Use named exports:
// ❌ tree shaking off
export default { formatDate, parseDate, diffDays };
// ✅ named exports, each removable
export function formatDate() {}
export function parseDate() {}
export function diffDays() {}
(4) Dynamic import paths must be statically enumerable. To load icons by name, provide a literal map (so the bundler can expand into static branches), not string concatenation:
// ❌ bundler can't enumerate, keeps all
const mod = await import(`./icons/${name}.js`);
// ✅ explicit map: bundler sees 120 deterministic candidates, unused can be shaken out
const ICONS = {
heart: () => import('./icons/heart.js'),
star: () => import('./icons/star.js'),
};
Discipline side: when adding a dependency, first check its package.json for "type": "module" and sideEffects. Those two fields decide whether it's 'shakable ESM' or 'a solid rock' in your bundle. CJS/UMD entries in an ESM project are essentially unshakable—that's a selection-stage check (Day58 will expand).
2.6 Step 6: Wire the Three Tables into Your Process
# .github/workflows/bundle-budget.yml
- run: npm ci && npm run build -- --json > stats.json # produce chunk manifest
- run: node scripts/budget-gate.mjs stats.json # four red lines, exit 1 on breach
- run: npx size-limit # or bundlemon, historical curve
- run: node scripts/budget-gate.mjs stats.json --json > budget.json
- uses: actions/upload-artifact@v4
with: { name: bundle-budget, path: budget.json }
Thresholds should be per-route, not a global number. The home page's 1000KB red line shouldn't apply to settings pages. Experience values (using Day25's 'bytes → parse time' conversion):
| Metric | Suggested red line | Basis |
|---|---|---|
| Entry closure minified | ≤ 1000KB | parse ≈ 1s magnitude, eats most of TBT budget |
| Entry closure gzip | ≤ 330KB | ≈ 260ms transfer on 4G |
| Initial chunk count | ≤ 6 | beyond that, request overhead and compression loss bite |
| Per-module invalidation | ≤ 400KB | amount returning users re-download per release |
| Business module duplication | 0 | no exceptions |
2.7 Discipline Checklist
- Measure invalidation surface before talking about granularity. Until
[contenthash]+ deterministic ids are in place, any split adjustment may be negative-return. - Don't lazy the entry route. Lazying part of the first screen = actively adding an RTT.
- Shared modules must be hoisted. The criterion is 'does any module appear in ≥2 chunks reachable by the same user', not 'how many routes did I split'.
- Set granularity by V × k, not bundle size. Lots of revisits and frequent releases → split finer; one-time traffic → split coarser.
minSizeis the price line for compression loss;minChunksis the hoisting switch. Don't mix them.sideEffects: falseis a factual declaration. Wrongly set, it fails silently, not with an error.export default {}as a surface kills tree shaking. Use named exports.- Dynamic import paths can't be concatenated. Keep them statically enumerable, otherwise all branches stay.
- The CI red line must include 'duplication bytes = 0'. It's the cheapest, most sensitive check.
- Two consecutive builds must produce identical hashes. If they differ, there's non-determinism; fix that before any other optimization.
III. Application Scenarios
Scenario 1: First screen cut from 2.1MB to 960KB, but LCP barely moved
Symptom: bundle analyzer shows first-screen chunk dropped from 2190KB to 960KB (−56%), LCP only from 3.1s to 3.0s.
Cause: LCP is determined by when the largest first-screen element renders, and that element is often stuck on images and critical CSS, not in the 960KB (images/fonts are Day31's topic). Bundle gains show up in TBT/INP (parse and execute are main-thread work), not LCP.
Action: change the observation metric—bundle changes map to TBT, INP, and the number of long tasks after FCP; LCP is for images and the critical rendering path. Using the wrong metric is worse than not optimizing: you'll twist an unrelated knob to chase an LCP number.
Scenario 2: After route splitting, navigation got slower
Symptom: switching from a single bundle to route lazy loading made first screen faster, but every menu click now shows a 400–600ms white screen.
Cause: two factors stack. ① Shared modules aren't hoisted; each route chunk carries its own copy of Table/Form/DateUtil (PART 2: order hop 186.66KB instead of 109.96KB); ② dynamic import adds one RTT per navigation.
Action: first add splitChunks.cacheGroups (minChunks: 2, chunks: 'all') to drive duplication to 0; then add route prefetch on hover/entering viewport:
// Prefetch before navigation: move the layer-1 RTT into the user's 200ms of hesitation
const order = () => import(/* webpackPrefetch: true */ './pages/order');
// Trigger on hover
<Link onMouseEnter={() => order()} to="/order">Orders</Link>
Scenario 3: A product shipping 5 times a day splits to 192 chunks
Symptom: a team follows the 'optimal granularity' and splits the first screen to 192 chunks; FCP +679.78ms, revisit saves 979.7ms each, looks good on paper. After launch, CDN hit rate doesn't change, but build time doubles, release debugging is painful, and Safari occasionally 404s on some chunks (mixed old/new chunks during release).
Cause: three things the model doesn't model: number of build artifacts, number of CDN cache entries, and the old-new version coexistence window. More chunks means a higher chance that a user holding an old HTML requests a new chunk during the release instant.
Action: pull granularity back to 16–32 chunks (PART 3's 3→4 marginal net benefit is 4.1x the 96→192 step), and do two things: ① keep the previous version's chunks (Cache-Control: max-age=31536000 + keep N old version directories); ② short-cache index.html plus atomic release. Release management is Day55's topic, but it determines how fine you dare to split.
Scenario 4: A single filename rule change halves the total bill
Symptom: a refactor changes [fullhash] to [contenthash], zero logic changes, and the 'every release re-downloads 1MB' phenomenon disappears.
Numbers: PART 4's total from 23876.62ms → 8981.65ms (2.66x), per-version invalid bytes from 960KB → 236KB. There's also the 'split but didn't fix hash' middle case at 24310.32ms—worse than not splitting.
Action: make this a build-time assertion:
// Two consecutive builds must produce identical hashes
const a = JSON.parse(execSync('node build.js --manifest').toString());
const b = JSON.parse(execSync('node build.js --manifest').toString());
assert.deepEqual(Object.keys(a).sort().map(k => a[k]), Object.keys(b).sort().map(k => b[k]));
Scenario 5: import { debounce } from 'lodash-es' grew the bundle by 190KB
Symptom: switched from lodash to lodash-es expecting a slimmer bundle; nothing changed. Cause: early versions of lodash-es's package.json lacked sideEffects: false, or the project used import _ from 'lodash-es' as a namespace import. PART B's fourth row is exactly this shape: 195.1KB.
Action: ① verify the dependency version declares sideEffects: false; ② always use named imports; ③ use deep imports like import { debounce } from 'lodash-es/debounce' as a control—if deep import is significantly smaller, tree shaking isn't working in your config.
Scenario 6: White screen after declaring sideEffects: false
Symptom: after adding "sideEffects": false to an internal component library, some pages lose all styles and customElements.define reports unregistered elements.
Cause: PART C's line return broken. Style imports (import './button.css'), polyfills, customElements.define, dayjs.extend, Reflect.metadata—these 'import-takes-effect' modules have observable top-level behavior and get stripped when declared pure.
Action: write sideEffects as an array precisely listing the 'has side effects' paths (2.5's first code block). Defaulting to false with an exception list is more dangerous than 'unknown', because it turns 'I didn't think about it' into 'I guarantee there are none.'
Scenario 7: On an HTTP/1.1 link, 32 chunks are 226ms slower than 16
Symptom: locally and in staging it's h2; switching to 32 chunks is only 5% slower. In a canary on an older link, the same output is slower by 19.6% (PART 1: h1 16 chunks 1448.48ms, 32 chunks 1674.64ms; h2 respectively 1338.48 / 1399.64).
Cause: each domain gets 6 concurrent connections. 32 chunks = 5 queue rounds, each a RTT.
Action: ① verify the actual link's protocol (response.protocol, don't trust config); ② on h1 links keep chunk count ≤12; ③ long term, ensure h2 end-to-end—'we use h2' must be measured, not configured.
Scenario 8: A modal triggers 3 RTTs
Symptom: clicking 'Export report' spins for 600ms before content appears.
Cause: report/index → dynamic import chart → dynamic import echarts; three levels of nesting = 3 RTTs = 165ms, plus download and parse at each level.
Action: merge modules known to be used together (chart with echarts into one chunk), and hoist the first level to layer 0 with modulepreload:
<link rel="modulepreload" href="/assets/report-chart.g7h8i9.js">
Criterion: depth > 2 is a design defect. From layer 3 onward, each RTT pays for a feature the user might not even need.
Scenario 9: The vendor chunk changes every release
Symptom: a one-line button text change in business code flips the hash of vendor.[hash].js, and users re-download 300KB.
Cause: three possibilities, by probability: ① runtimeChunk not enabled, so webpack's chunk map lives in the main chunk, causing a ripple; ② moduleIds isn't deterministic, adding a module shifts ids; ③ some plugin injects a version number or timestamp banner into every chunk.
Action: check in order ①→②→③, and after each fix run the 'two consecutive builds identical hash' assertion. That assertion should live in CI like unit tests.
IV. Summary
Compress this episode into four sentences:
- Splitting doesn't create gains; exclusion creates gains. The same 960KB, split into 16 slices, costs 60.27ms; into 32 slices costs 121.43ms; excluding 1230KB from the first screen saves 1492.39ms. 'Make the bundle smaller' gets 90% of its return from subtraction, 10% from reshaping.
- All of splitting's gains come from shared hoisting. Three-navigation cumulative transfer: 671.9KB (unhoisted) > 657KB (unsplit), and 525.19KB (hoisted). The criterion is 'does any module appear in ≥2 chunks reachable by the same user', not 'how many routes did I split'.
- The third budget is invalidation surface, and it's bigger than the other two. Over 20 revisits: 24310.32 (route + fullhash) → 20480.65 (unstable id) → 8981.65 (contenthash + deterministic id), a 2.7x difference with zero code changes. Fixing this before tuning granularity is the cheapest suggestion in this episode.
- The optimal granularity is set by V × k, not by bundle size. V=1 optimum 1 chunk, V=5 optimum 96, V=20 optimum 192; marginal net benefit decays from 2964.69 to 722.61. 'Best splitting practice' has no universal answer—it needs to know how many times your users return and how often you ship.
Finally, back to the sentence most often said backwards: bundle optimization isn't making the number smaller; it's deciding which bytes deserve to appear in the user's first RTT, and whether they deserve to be cached. The former is exclusion, the latter is naming. Both are an order of magnitude more important than 'splitting finer.'
Environment
Scripts (day30-assets/, all zero-dependency, deterministic, exit 1 on failed assertion):
| Script | Covers | Assertion count |
|---|---|---|
chunk-budget.mjs |
PART 1 FCP / PART 2 navigation cumulative / PART 3 granularity scan / PART 4 hash & id | 58 |
treeshake-model.mjs |
PART A reachability engine / PART B five variants / PART C side-effect executable proof | 8 |
budget-gate.mjs |
CI four gates (initial bytes, gzip, request count, invalidation, duplication) | exit-code check |
stats.sample.json / stats.bad.json |
good sample / naive split sample | — |
Reproduce:
node day30-assets/chunk-budget.mjs
node day30-assets/treeshake-model.mjs
node day30-assets/budget-gate.mjs day30-assets/stats.sample.json # all PASS, exit 0
node day30-assets/budget-gate.mjs day30-assets/stats.bad.json # 3 FAIL, exit 1
On the origin of the unit constants (important, don't skip): NAV_RTT_MS=55, TRANSFER_KB_MS=0.8 (≈1250KB/s), PARSE_KB_MS=1.0, REQ_OVERHEAD_MS=2.5, CHUNK_PARSE_OVERHEAD_MS=0.6, CODE_CACHE_FACTOR=0.15, H1_WAVE_SIZE=6, and the gzip ratio curve 0.45 − 0.05·log10(KB) (clamped between 0.30 and 0.45)—these are magnitude constants calibrated for a mid-range device, not any citable public measurement.
I searched: web.dev, MDN, Chrome for Developers, and Lighthouse all publish criteria and thresholds (LCP 2.5s / INP 200ms / CLS 0.1 at p75; Long Task threshold 50ms; modulepreload semantics; the sideEffects field consumed by webpack/rollup/esbuild), but none publishes numbers like 'how many milliseconds per KB of minified JS parsing', 'how many milliseconds per extra request', or 'how gzip ratio changes with size'. So treat this episode's milliseconds as relative quantities in the same coordinate system: their purpose is to answer 'exclusion vs reshaping value', 'fuzzy optimization vs locking invalidation surface—which has more leverage', not 'your machine will take 2770.6ms'.
To counter this, chunk-budget.mjs PART 3 runs a 14-point granularity scan, pinning three order-preserving conclusions across all points: 'invalid bytes monotonically non-increasing with granularity', 'FCP monotonically non-decreasing with chunk count', 'marginal net benefit diminishing'. PART 1/2/4 conclusions rely only on the 'additive costs + one RTT' structure, not on specific unit values.
Additional simplifications and known optimistic biases of each script:
chunk-budget.mjs: ① treats same-layer chunks as parallel download with serial bandwidth accumulation; doesn't model TCP slow start, h2 flow control windows, or head-of-line blocking; ② parse cost is linear in minified KB; real V8 has lazy parsing (pre-parser) and function-level lazy compilation, so large functions vs small functions have different unit costs; ③CODE_CACHE_FACTOR=0.15is a magnitude estimate of 're-parse after V8 code cache hit'; doesn't model code cache capacity limits or eviction; ④ 'modules changed in one release' is synthesized with a fixed-seed PRNG (70% concentrated in a hot directory), not any real repo's churn distribution—its role is to make 'invalidation surface' a reproducible arithmetic; ⑤ invalidation uses worst-case (all chunks containing the module fail); real bundlers have finer hash propagation, possibly only partial invalidation; ⑥ doesn't model CSS invalidation,Cache-Controldifferences, CDN edge hit rates, or the old-new version coexistence window during release; ⑦ doesn't model Terser/Rollup cross-module optimizations (property name compression, constant folding)—splitting into multiple chunks disables this layer, so 'fine-splitting cost' is usually higher in real projects than this episode shows; ⑧ PART 4's 'unstable id' models vendor/shared/dash three chunks all failing; real impact depends on how many modules the id shift affects.treeshake-model.mjs: ① the engine is simplified:export *name resolution uses 'same-name-matches' approximation; real webpack/rollup have fuller symbol tables and__esModuleinterop handling; ② 'undeclaredsideEffects⇒ re-exported modules fully retained' is a model of documented behavior; specific retention varies by bundler and version, so check your actual output; ③ the icon module size (1.6KB each) and count (120) are constructed; ④ PART C usesnew Function+ parameter injection ofglobalThisto simulate a sandbox, not real bundler behavior; it proves the causality 'deleting top-level side effects leads to silent errors', not 'webpack will definitely delete this way'.budget-gate.mjs: ① needs webpackstats.jsonor vitemanifest.jsonnormalized to the format the script expects (chunks[].{name,kb,gzipKb,modules,staticImports,dynamic}); that normalization is bundler-specific; ② invalidation uses worst-case and is conservative (good for CI: better to report early); ③ duplication detection covers only business modules;node_modulesintentionally duplicated across multiple entries isn't counted.
Version and semantic conventions (separate from 'model uncertainty'—these are facts about current implementations; verify each for your target environment before adopting):
[contenthash]vs[fullhash]: in webpack 5,[hash]is deprecated;[fullhash]is the hash of the entire build;[contenthash]is the hash of that resource's content; chunk filenames must use[contenthash]. In production modeoptimization.moduleIdsandchunkIdsboth default to'deterministic', but writing them explicitly is necessary—because reverting to'natural'or'named'takes one line, and the cost is 2.3x of this episode's magnitude.splitChunksdefaults: webpack 5'schunksdefaults to'async',minSizedefaults to ~20000,minChunksdefaults to 1. 'Default config does no initial shared hoisting' is the most common reason for 'split routes but got no cache benefit'.sideEffects: apackage.jsonfield read by webpack / rollup / esbuild; semantics: 'modules in this package have no observable top-level behavior.' The consequence of miswriting is deleted code, not an error.modulepreload:<link rel="modulepreload">makes the browser fetch and pre-parse the module during HTML parsing (MDN and HTML spec); that's the basis for 'flattening depth back to layer 0.' Vite auto-generates it viabuild.modulePreload; webpack needs a tool likepreload-webpack-pluginor magic comments.export default { ... }and tree shaking: namespace object members are runtime property accesses; static analysis can't determine 'you use only one'. This isn't an implementation defect; it's a boundary of static analysis.- Dynamic import with an expression: when
import(expr)whereexprisn't a literal, the bundler can't enumerate candidates (webpack gives a 'Critical dependency' warning) and keeps the entire directory or all candidate branches. - HTTP/1.1's 6 connections per domain: from browser implementation conventions (Chrome/Firefox/Safari are 6), not a spec mandate; h2 uses a single multiplexed connection but is limited by flow control windows.
- These version-dependent facts change with versions; this episode's model and assertions don't depend on them; they only affect 'which config options correspond to which strategy in this episode.'
Six real problems self-testing exposed and fixed (kept because they're the type of 'model errors that invert conclusions', and each now has an assertion guarding it):
- Wrote 'larger file compresses better' as comparing absolute bytes of two different totals (
chunk-budget.mjsPART 1): initial assertion said 'single bundle's gzip < sum of 4 slices'; the run gave 657KB (2190KB single) which is obviously > 317.38KB (960KB four slices)—comparing absolute quantities with different denominators is a fundamental logic error. After switching to comparing gzip ratios (0.301 vs 0.361 vs 0.376), the correct conclusion 'splitting finer adds 43.29KB / +13.6% transfer' emerged. - Asserted 'unhoisted split is also slower than single bundle'—the direction was wrong: run gave 2718.72ms vs 2770.6ms, so it was 1.9% faster. That failed assertion actually produced Difficulty 2's precise statement: unhoisted splitting 'pays 2.3% more bytes to get 1.9% time'—basically wasted effort. The assertion now checks 'time benefit < 5% and bytes more expensive', not 'slower'.
- h1 premium threshold was hardcoded at 1.1x: 16 slices on h1 cost only 8.2% more (1448.48 vs 1338.48), the assertion failed. Corrected to relative assertion: '32-slice h1 premium (19.6%) must be > 1.5x the 16-slice h1 premium (8.2%)'—that captures the true conclusion that queueing premium amplifies with chunk count.
- The granularity grid had duplicate rows:
groupSize = ceil(N/n)made n=12/16/20/24/32 all land on the same chunk count, producing three identical rows, marginal benefit computed as0, and the 'diminishing marginal' assertion failed. After switching to the actual chunk count as key, the grid became 1/2/3/4/6/8/12/16/24/32/48/64/96/192 and the marginal sequence became meaningful. - Assumed 'splitting to 2 slices halves invalid bytes': the run dropped only from 960KB to 912KB (invalid chunk count 1.9). Reason: each version changes an average of 11.4 modules, and when changes scatter, two giant 480KB slices are almost always both touched. That failed assertion directly produced Difficulty 3's core conclusion: chunk count must exceed the number of scattered change clusters per version before splitting starts to yield cache gains; 1→2 slices even shows negative net benefit (−233.4ms).
treeshake-model.mjsPART C's first version returnedokon both sides: becausenew Function'sglobalThis.__X = truepolluted the real global object, and the second run read the residue from the first. After injectingglobalThisas a function parameter (new Function('globalThis', src)thenfn.call({}, {})), it reproducedreturn broken—and incidentally confirmed the real shape of such bugs: not an error, but a silent wrong return.
On 'why there's no name to put on a slogan': this episode's conclusions are all trades—first-screen time for invalidation surface, request count for compression ratio, granularity for operational cost. None compress into 'do this and you're right.' If one sentence must stay, it's that execution order: first exclude, then hoist, then lock the invalidation surface, and only then tune granularity.
Top 1 from juejin.cn, machine-translated. The original thread is authoritative.
Chunk splitting must also weigh the scope of cache invalidation.