跪拜 Guibai
← All articles
Frontend

Bundle Optimization: Splitting Chunks Can Make First Paint Slower—The Real Lever Is Invalidation

By 前端阿凡 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

A wrong hashing strategy can make code splitting slower than no splitting at all, costing users 2.7x in total load time over a month of revisits. The fix—contenthash filenames and deterministic module ids—is cheap and should be the first step in any bundle budget.

Summary

Bundle optimization isn't about shrinking numbers—it's balancing three budgets: initial bytes, request shape, and invalidation surface. The first two live in the build config; the third lives in the filename's hash, and it carries the most leverage. Splitting a 960KB entry into 16 slices costs 60ms on first paint; excluding 1230KB from the critical path saves 1492ms. Route splitting only pays when shared modules are hoisted—duplicating them makes three navigations transfer 671.9KB, more than the 657KB single bundle.

Over twenty revisits, using fullhash makes splitting lose 2.7x—24310ms vs 23876ms for no splitting—while contenthash plus deterministic module ids cuts that to 8981ms. Tree shaking boils down to a sideEffects declaration: omitting it inflates a 4KB import to 194KB. The optimal chunk granularity depends on revisit frequency and release cadence; V=1 wants one chunk, V=20 wants 192. Teams should measure invalidation surface, not total size.

Takeaways
— Splitting a 960KB entry into 16 chunks adds 60.27ms to first paint; into 32 chunks adds 121.43ms of total overhead.
— Excluding 1230KB from the critical path saves 1492.39ms; reshaping the same 960KB into more chunks costs about 121ms.
— Route splitting without hoisting shared modules transfers 671.9KB over three navigations, 2.3% more than the unsplit 657KB bundle.
— All gains from route splitting come from hoisting shared modules into separate chunks; the act of splitting itself adds nothing.
— Using fullhash for chunk filenames makes code splitting worse than not splitting: 24310.32ms over 20 revisits vs 23876.62ms for a single bundle.
— Switching to contenthash and deterministic module ids cuts the same workload to 8981.65ms, a 2.7x improvement with zero code changes.
— Unstable module ids alone inflate invalidation surface from 236KB to 780KB.
— A single package.json sideEffects:false declaration on a barrel file saves 190.4KB (4.3KB vs 194.7KB for an unstated import).
— Dynamic import() paths must be statically enumerable; string concatenation makes bundlers keep all branches.
— modulepreload on entry-route chunks saves one RTT (51.9ms) and flattens dependency depth to zero.
— Optimal chunk count depends on revisit frequency (V) and release cadence (k): V=1 favors 1 chunk, V=5 favors 96, V=20 favors 192.
— Marginal net benefit of splitting drops from 2964.69ms (3→4 chunks) to 722.61ms (96→192 chunks), a 4.1x difference.
— Entry routes should be statically imported, not lazy-loaded; lazying them adds an RTT to the critical path.
Conclusions

The strongest signal is that invalidation surface, the metric nobody tracks, outweighs both initial bytes and request count; teams measuring only bundle size are optimizing the wrong number.

The model's constants are calibrated estimates, but the ordering of conclusions is robust because it rests on additive costs and structural invariants; the relative trade-offs matter more than the absolute milliseconds.

The sideEffects story is a reminder that tree shaking is a contract between package authors and bundlers; omitting one field costs 46x in bytes, which is more than any splitChunks tuning can recover.

Making dependency depth >2 a design defect is a useful rule most developers violate without realizing, and it ties directly to RTT accumulation that no CDN can mask.

Concepts & terms
contenthash
A filename hash in webpack based on the file's own content; the filename changes only when that file's content changes, allowing long-term caching.
fullhash
A hash of the entire build output; any change to any module changes every chunk's filename, invalidating all caches.
invalidation surface
The set of bytes a returning user must re-download and re-parse after a new release, determined by which modules changed and which chunks contain them.
sideEffects
A package.json field that tells bundlers a module's top-level code has no observable side effects, allowing unused exports to be safely dropped.
modulepreload
An HTML link relation that fetches and pre-parses a module before it's needed, flattening the dependency depth to the initial level.
deterministic module ids
A webpack setting that assigns module IDs based on content rather than insertion order, so adding or removing modules doesn't shift all subsequent IDs and invalidate unrelated chunks.
From the discussion
Featured comments
卷土的土

Chunk splitting must also weigh the scope of cache invalidation.

See top comments, translated →
Source: juejin.cn ↗ Google Translate ↗ Backup ↗