TL;DR. 7 manual groups split vendor (initial × 3 tiers + async × 3 tiers) + initial app code; async app code is left to rolldown's auto-chunking. Config: libs/nx/plugin/vite/src/executors/build/executor.ts.
- The configuration
- Effects — what the config produces
- Next direction — Layer 3 (app-code consolidation)
- Background — the three layers of chunk optimization
- Open issues
- Performance — V4.2 vs B baseline
- References
const BROTLI_TARGET_PER_CHUNK = 20 * 1024; // 20 KB brotli'd — Calibre's "worth its own request" floor
const COMPRESSION_RATIO_TYPICAL = 5; // JS source → brotli ≈ 5× (median, real-world)
const TREE_SHAKE_SAFETY_FACTOR = 3; // pad for heavy-DCE cases (e.g. @floating-ui/react 78×)
const PACKAGE_OWN_CHUNK_MIN_SOURCE =
BROTLI_TARGET_PER_CHUNK * COMPRESSION_RATIO_TYPICAL * TREE_SHAKE_SAFETY_FACTOR;
// 20 KB × 5 × 3 = 300 KB source — minimum for a package to deserve its own tier-1 chunk
const SCOPE_MERGE_MIN_SOURCE =
BROTLI_TARGET_PER_CHUNK * COMPRESSION_RATIO_TYPICAL;
// 20 KB × 5 = 100 KB source — minimum for a scope-merged tier-2 chunk to emit
const CHUNK_MAX_SOURCE = 1024 * 1024;
// 1 MB source ≈ 200 KB brotli'd — chunk size ceiling used by vendor groupsWhy source bytes and not brotli'd? rolldown's minSize/maxSize operate on source bytes (the size of files on disk, before tree-shake/minify/brotli). The bundler decides chunk assignment before any of those steps run, so source size is the only signal it has. The 3× safety factor compensates for heavily tree-shaken packages — e.g. @floating-ui/react ships 187 KB of source but DCE leaves only ~2 KB usable, a 78× ratio.
// 'node_modules/.pnpm/@angular+core@.../node_modules/@angular/core/index.js' → '@angular-core'
// 'node_modules/lodash-es/pick.js' → 'lodash-es'
// 'apps/client/src/main.ts' → null
function packageName(id: string): string | null {
const m = [...id.matchAll(/node_modules\/(@[^/]+\/[^/]+|[^/]+)/g)];
if (m.length === 0) return null;
return m[m.length - 1][1].replace(/\//g, '-'); // last match skips pnpm's .pnpm/ virtual dir
}
// '@angular/core' → 'angular'
// '@univerjs/sheets-ui' → 'univerjs'
// 'lodash-es' → null (unscoped — falls through to catch-all)
function scopeName(id: string): string | null {
const m = [...id.matchAll(/node_modules\/(@[^/]+\/[^/]+|[^/]+)/g)];
if (m.length === 0) return null;
const last = m[m.length - 1][1];
if (!last.startsWith('@')) return null;
return last.split('/')[0].slice(1); // '@angular' → 'angular'
}When name(id) returns null, rolldown skips this group for that module and tries the next-priority group. That's the mechanism that lets unscoped packages bypass tier 2 (scope-merge) and fall to tier 3 (catch-all).
codeSplitting: {
// Required to keep tsconfig-aliased app code out of vendor chunks
// (see vite-migration-study.md → "alias-leak").
includeDependenciesRecursively: false,
groups: [
// priority 30 — Tier 1 INITIAL · per-package
// Captures: big vendor packages reachable from the page entry without a
// dynamic import. E.g. @angular/core (~500 KB source), zone.js,
// rxjs. Each becomes its own `vendor-{pkg}` chunk.
// Skips: packages under 300 KB source (fall to tier 2); app code
// (no /node_modules/ in path).
{
name: (id) => { const p = packageName(id); return p ? `vendor-${p}` : null; },
tags: ['$initial'],
test: /\/node_modules\//,
minSize: PACKAGE_OWN_CHUNK_MIN_SOURCE,
priority: 30,
},
// priority 26 — Tier 2 INITIAL · @scope-merge
// Captures: @-scoped packages that didn't clear tier 1's 300 KB floor.
// All leftover @angular/* packages merge into `vendor-angular`;
// same for @floating-ui/*, @ngx-translate/*, etc.
// Skips: unscoped packages (scopeName returns null → fall to tier 3);
// scopes whose combined source < 100 KB (skip → tier 3).
// Splits: if the merged chunk exceeds CHUNK_MAX_SOURCE (matters for
// huge scopes like @univerjs/*).
{
name: (id) => { const s = scopeName(id); return s ? `vendor-${s}` : null; },
tags: ['$initial'],
test: /\/node_modules\//,
minSize: SCOPE_MERGE_MIN_SOURCE,
maxSize: CHUNK_MAX_SOURCE,
priority: 26,
},
// priority 22 — Tier 3 INITIAL · catch-all
// Captures: everything reachable from the page entry that didn't fit
// above — unscoped packages (lodash, dayjs, tslib), lonely-
// scoped leftovers, anything tiny. Single chunk:
// `vendor-common-initial`.
// Critical: this priority MUST outrank all async tiers below. A module
// reached both statically AND lazily is $initial-tagged AND
// also matches the async groups (which have no tags filter).
// If an async tier outranked this, those dual-reach modules
// would leak into async chunks instead of anchoring here.
{
name: 'vendor-common-initial',
tags: ['$initial'],
test: /\/node_modules\//,
priority: 22,
},
// priority 18 — Tier 1 ASYNC · per-package
// Captures: big vendor packages reached only via dynamic import() — i.e.
// the editor / chart / canvas libs that load when you open a
// feature. E.g. mermaid, gojs, tldraw, each @univerjs/*
// package, pdfjs-dist. Each becomes `vendor-{pkg}-async`.
// Same shape as tier 1 initial; no `tags` filter so async-only modules
// also match.
{
name: (id) => { const p = packageName(id); return p ? `vendor-${p}-async` : null; },
test: /\/node_modules\//,
minSize: PACKAGE_OWN_CHUNK_MIN_SOURCE,
priority: 18,
},
// priority 14 — Tier 2 ASYNC · @scope-merge
// Captures: async-only @-scoped leftovers. Mirror of tier 2 initial.
{
name: (id) => { const s = scopeName(id); return s ? `vendor-${s}-async` : null; },
test: /\/node_modules\//,
minSize: SCOPE_MERGE_MIN_SOURCE,
maxSize: CHUNK_MAX_SOURCE,
priority: 14,
},
// priority 10 — Tier 3 ASYNC · catch-all
// Captures: everything async-vendor that didn't fit above. Single chunk:
// `vendor-common-async`. No maxSize for now — output shape
// hasn't warranted it yet (it's ~970 KB brotli'd; see Open
// Issues).
{
name: 'vendor-common-async',
test: /\/node_modules\//,
priority: 10,
},
// priority 6 — initial-deps · app-side initial-graph split
// Captures: app source code (anything NOT in /node_modules/) that's
// reachable from the page entry without a dynamic import —
// bootstrap, root component, all services + utils they
// statically import.
// Skips: modules already claimed by the six vendor groups above.
// Splits: rolldown auto-splits by entries-reachability bitset; minSize
// merges sub-30 KB-source crumbs into a sibling chunk to
// avoid wasteful tiny requests; maxSize caps each piece for
// parallel download.
{
name: 'initial-deps',
tags: ['$initial'],
minSize: 30 * 1024,
maxSize: 3 * 1024 * 1024,
priority: 6,
},
// No Layer 3 group. Async app code is left to rolldown's auto-chunking
// + `experimental.chunkOptimization` (default true). Six manual
// approaches were tried (V4 through V4.5); each regressed something.
// See vite-migration-study.md → "Layer 3 investigation".
],
}includeDependenciesRecursively: false— the alias-leak gate. Without this, rolldown's recursive dependency capture treats tsconfig-paths-aliased app code (@cu/...imports) as if it lived under the importing package'snode_modules, pulling app code into vendor chunks. See the minimal repro.experimental.chunkOptimization— left at defaulttrue. RunsmergeCommonChunks+avoidRedundantChunkLoadsas a post-emission pass on the chunk graph. This is the substitute for a manual Layer 3 group (see Background → Layer 3).
| priority | group name (output) | layer | what it catches |
|---|---|---|---|
| 30 | vendor-{pkg} |
L2 | initial-graph big vendor packages |
| 26 | vendor-{scope} |
L2 | initial-graph @-scoped vendor leftovers |
| 22 | vendor-common-initial |
L2 | initial-graph small / unscoped vendors |
| 18 | vendor-{pkg}-async |
L2 | async-only big vendor packages |
| 14 | vendor-{scope}-async |
L2 | async-only @-scoped vendor leftovers |
| 10 | vendor-common-async |
L2 | async-only small / unscoped vendors |
| 6 | initial-deps-* |
L1 | initial app source (auto-split by reachability + maxSize) |
| — | auto | L3 | async app code (rolldown auto-chunking + chunkOptimization) |
Measured on the dist/compare/vite-v4.2/statics/ snapshot, brotli'd output bytes:
| group / pattern | priority | chunks | total brotli'd | avg per chunk |
|---|---|---|---|---|
vendor-@scope-pkg (T1 initial scoped) |
30 | 9 | 303 KB | 33.7 KB |
vendor-pkg (T1 initial unscoped) |
30 | 3 | 68 KB | 22.7 KB |
vendor-{scope} (T2 initial scope-merge) |
26 | 5 | 146 KB | 29.2 KB |
vendor-common-initial (T3 initial catch-all) |
22 | 1 | 415 KB | 415 KB |
vendor-@scope-pkg-async (T1 async scoped) |
18 | 26 | 3,155 KB | 121.4 KB |
vendor-pkg-async (T1 async unscoped) |
18 | 28 | 2,114 KB | 75.5 KB |
vendor-{scope}-async (T2 async scope-merge) |
14 | 28 | 1,145 KB | 40.9 KB |
vendor-common-async (T3 async catch-all) |
10 | 1 | 969 KB | 969 KB |
initial-deps-* (L1) |
6 | 57 | 2,846 KB | 49.9 KB |
rolldown-runtime |
— | 1 | 0.8 KB | 0.8 KB |
workers (pdf.worker, heic-image.worker) |
— | 2 | 637 KB | 318.6 KB |
| auto-chunked (L3 / unmanaged) | — | 3,184 | 16,352 KB | 5.1 KB |
| TOTAL | 3,345 | 28,152 KB |
A few things visible at a glance:
- Layer 2 vendor groups produce 101 chunks total (9+3+5+1 initial + 26+28+28+1 async). That's the structured part of the dist — well-sized chunks averaging 30–120 KB brotli'd, in Calibre's sweet spot.
- Layer 1
initial-deps-*produces 57 chunks averaging 50 KB brotli'd (V4.2). The V4.6 reshape (minSize: 30 KB+maxSize: 3 MB) consolidates this to 17 chunks averaging 158 KB; the rest of the table is unchanged. - Layer 3 (auto-chunked) produces 3,184 chunks averaging 5 KB each — this is the long-tail problem the V4.x investigation left unsolved. They aggregate to 16.4 MB (~58% of the total dist).
- The two largest single chunks are catch-alls:
vendor-common-async(969 KB) andvendor-common-initial(415 KB). Both are deferred concerns (see "Open issues" below).
This is the open frontier, not a minor issue. The other items in "Open issues" below are bounded (50 KB Angular leak, one big catch-all, one app-side import to trace); Layer 3 is the unsolved general problem.
State. 3,628 auto-feature-route chunks across the dist (signup loads ~128 of them). Every manual approach we tried — shared group, B-style common group, entriesAware, entriesAware + maxModuleSize — introduced regressions vs leaving it to auto-chunking + chunkOptimization. The LobeHub chunking case study landed at the same dead-end: feature-domain grouping and consumer-set clustering both failed.
Why it's hard. rolldown's existing knobs operate on source-byte signals (minSize, maxSize, minShareCount) or coarse reachability bitsets (entriesAware). None of them encode "this module is shared by routes A, B, C but not D" with the granularity needed to make a non-pathological grouping. The bundler doesn't know which routes the user actually visits together.
Tools that might help when they ship (or with custom plugin work):
- rolldown PR #9488 — per-group
includeDependenciesRecursively. Would let a single group recursively capture a route's static graph without compromising the global alias-leak gate. Currently OPEN. - Per-route reachability tags (no current rolldown support) — would allow per-route group filtering similar to how
$initialworks today. - A post-emission custom plugin that reads the chunk graph and merges by access-pattern data (production analytics → "routes A and B are co-loaded by 80% of sessions") rather than static signals.
Decision. Ship V4.2 as is. Revisit when there's measurable user impact or upstream tooling lands.
Every modern bundler making chunks for a large SPA hits the same three problems. The ClickUp config above addresses Layers 1 and 2 with manual groups and leaves Layer 3 to rolldown's auto-chunking.
Goal: the page's static graph (everything that loads before any dynamic import fires) shouldn't be one giant chunk. Split it into N parallel-loadable pieces so the browser doesn't wait on a single big download + parse.
Knob: maxSize on a group capturing $initial-tagged modules. The bundler splits the captured set into pieces close to maxSize.
Tradeoff: bigger pieces = fewer requests but slower parallelism; smaller pieces = more parallelism but more HTTP overhead. Sweet spot is roughly 200–500 KB brotli'd per piece per Calibre.
In our config, this is the initial-deps group at priority 6.
Goal: cache node_modules code separately from app code because vendor changes less often than app changes. A patch deploy that bumps @angular/core should invalidate as few user-cached chunks as possible.
Standard three-tier cascade:
- Per-package — large libraries get their own chunk (
vendor-@angular-core,vendor-mermaid-async). Best cache granularity. - Scope-merge — small leftovers from
@-scopedpackages merge by scope (vendor-angularcontains@angular/animations+@angular/elements+ …). - Catch-all — everything else lands in one chunk per track.
Knobs: test (regex), minSize (group size floor), maxSize (chunk size ceiling), priority (resolves multi-match), tags (filter by reachability — e.g. $initial).
Cross-cutting concern: a vendor module reached both statically (from the entry) AND lazily (from a route) is $initial-tagged AND matches async-track groups. The priority chain has to put all initial-track tiers above all async-track tiers to anchor those dual-reach modules on the initial side. This is why priority 22 (initial catch-all) sits above priority 18 (async per-package) in the config above.
Goal: cross-route shared modules in src/ shouldn't be duplicated across many feature chunks. A Toast component used by 30 routes shouldn't ship as 30 copies — ideally one chunk per usage cluster.
This layer is the hardest. Available knobs: split by entry-reachability (entriesAware: true), require a minimum share count (minShareCount), filter by module size (maxModuleSize), or hand-curate a whitelist. Each approach has failure modes (over-fetch, duplication, maintenance burden). The ClickUp Layer 3 investigation tried six variants (V4 through V4.5); each regressed something.
We leave Layer 3 to rolldown's experimental.chunkOptimization — its post-emission mergeCommonChunks / avoidRedundantChunkLoads passes operate on the actual chunk graph (not source modules), which avoids the over-fetch/duplication problems every manual approach hit.
Vite's Angular plugin re-processes pre-compiled .mjs files at load time, expanding the source rolldown's minSize check sees by ~2-3×. Result: 8 Angular-related vendor chunks pass the 300 KB source threshold despite their post-tree-shake output being only 2-10 KB brotli'd each. Total leak: ~50 KB brotli'd across 8 chunks. Decision: accept (noise vs 27.5 MB dist).
Full analysis: vite-migration-study.md → Known limit — Angular plugin source-byte inflation.
The tier 3 initial catch-all consolidates unscoped + lonely-scoped leftovers. After the V3.1 → V3.2 priority fix, it grew from ~162 KB to 415 KB because previously-dual-reached modules now anchor here. It ships on every page.
Mitigation options not yet applied:
- Add
maxSize: CHUNK_MAX_SOURCEto split into 3-4 parallel chunks - Use
entriesAware: trueon the initial catch-all (not yet tested)
Decision: deferred. The 415 KB falls within Calibre's "good for single large libs" band (100-500 KB brotli'd). Investigating further when there's measurable user impact.
The signup page's at-vis request set includes 128 auto-chunked feature/route chunks (496 KB total), of which task-editor.component alone is 125 KB. This is not a chunking-config problem — it's a code-level problem. Some service or shared module in signup's static graph is import()-ing task-editor, which transitively makes it part of signup's load.
Fixing this is a code investigation: trace the dynamic-import chain from signup's bootstrap and break the chain that pulls task-editor.
Decision: out of scope for chunking config; recorded as the highest-value Layer 3 win available if/when someone investigates.
Measured: median of 3 cold runs each. Same session, brotli q=11 pre-compressed, http-server with -b flag, Playwright + CDP, signup page (/signup).
| B (esbuild + Angular optimizer, ship today) | V4.2 (Vite-direct, our build) | |
|---|---|---|
| signup-form-visible (median ms) | 1,640 | 1,540 |
| JS reqs @ signup-visible | 47 | 331 |
| JS KB @ signup-visible (brotli'd) | 4,999 | 7,281 |
| JS reqs @ settle | 97 | 1,223 |
| JS KB @ settle (brotli'd) | 12,752 | 10,683 |
| Total JS chunks in dist | 1,246 | 3,345 |
| Total brotli'd dist | 29.6 MB | 27.5 MB |
Read:
- Time-to-signup-visible: V4.2 is 100 ms faster (~6%). Reproducible across sessions (earlier session showed similar gap).
- At-vis byte budget: V4.2 ships ~2.3 MB more. That's the bigger tradeoff. B's hand-curated
common*chunks (each 200-400 KB) consolidate small modules pre-rolldown via esbuild's bundling stage, giving B a smaller at-vis JS payload despite shipping ~10 MB more total dist (29.6 vs 27.5 MB). - At-vis request count: V4.2 is 7× higher (331 vs 47). HTTP/2 multiplexes them; per-request overhead is small but non-zero.
- At-settle total: V4.2 ships less (10.7 vs 12.8 MB). V4.2 ships fewer total bytes but more upfront; B ships more total but less at vis.
- Total dist size: V4.2 is 2.1 MB smaller — better per-package cache granularity at the cost of more requests.
The measurement caveat: Playwright's signup-form-visible detection uses polling, which is starved on many-small-chunks builds. V4.2 has 7× more requests; its measured time-to-vis is likely overestimated compared to what a human-eye filmstrip would show. A headed-Chrome filmstrip run would probably widen V4.2's win further. See vite-migration-study.md → measurement caveats.
V4.2 is time-competitive with B (consistently 50-100 ms faster on broadband). The byte tradeoff is real (~2× at vis) but the absolute numbers are within Russell's 365 KiB compressed 3-second budget when you exclude bytes that load after vis. On a P75 mobile connection the picture could differ — we haven't measured that.
- vite-migration-study.md — full investigation log (Layer 2 + Layer 3, 9 measured variants, alias-leak diagnostic)
- LobeHub chunking case study — the three-layer framework this doc adapts
- LobeHub Layer 3 notes — why Layer 3 stays unsolved
- hyf0/rolldown-chunking-alias-leak — minimal repro of the recursion+alias bug fixed by
includeDependenciesRecursively: false - rolldown PR #9488 — per-group
includeDependenciesRecursively(open) — would unlock a future Layer 3 approach - Calibre — Bundle size optimization — source of the 20 KB / 50 KB / 500 KB brotli thresholds
- web.dev — Granular chunking (Next.js + Google) — empirical study of request-count effects under HTTP/2
- Alex Russell — Performance Inequality Gap 2024 — the 300-365 KB compressed page budget