Skip to content

Instantly share code, notes, and snippets.

@hyfdev
Last active May 22, 2026 13:41
Show Gist options
  • Select an option

  • Save hyfdev/93d53718780efc57019cfe7d5e7fc1c4 to your computer and use it in GitHub Desktop.

Select an option

Save hyfdev/93d53718780efc57019cfe7d5e7fc1c4 to your computer and use it in GitHub Desktop.
Chunking strategy for ClickUp's Vite-direct build — 3-layer framework, config explained, open issues, perf vs baseline

Chunking strategy — ClickUp Vite-direct build

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.

Contents


The configuration

Threshold constants

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 groups

Why 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.

Helper functions

// '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).

The eight groups (descending priority)

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".
  ],
}

Two top-level options

  • 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's node_modules, pulling app code into vendor chunks. See the minimal repro.
  • experimental.chunkOptimization — left at default true. Runs mergeCommonChunks + avoidRedundantChunkLoads as a post-emission pass on the chunk graph. This is the substitute for a manual Layer 3 group (see Background → Layer 3).

Effects — what the config produces

Full priority chain at a glance

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)

What each group actually produces (V4.2 build)

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) and vendor-common-initial (415 KB). Both are deferred concerns (see "Open issues" below).

Next direction — Layer 3 (app-code consolidation)

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 $initial works 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.


Background — the three layers of chunk optimization

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.

Layer 1 — Initial app split

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.

Layer 2 — Vendor (node_modules) consolidation

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 @-scoped packages merge by scope (vendor-angular contains @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.

Layer 3 — App-code consolidation across routes

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.


Open issues

1. Angular plugin source-byte inflation defeats minSize for Angular packages

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.

2. vendor-common-initial is 415 KB brotli'd — bigger than ideal

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_SOURCE to split into 3-4 parallel chunks
  • Use entriesAware: true on 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.

3. task-editor.component (125 KB brotli'd) loads on signup

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.


Performance — V4.2 vs B baseline

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.

Bottom line

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.


References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment