Skip to content

Instantly share code, notes, and snippets.

@hyfdev
Last active April 19, 2026 15:51
Show Gist options
  • Select an option

  • Save hyfdev/65d63c6ab51842944f0467902bf5da61 to your computer and use it in GitHub Desktop.

Select an option

Save hyfdev/65d63c6ab51842944f0467902bf5da61 to your computer and use it in GitHub Desktop.
LobeHub SPA: Chunking Strategy Case Study — codeSplitting.groups best practice with $initial tag split

LobeHub SPA: Chunking Strategy — Case Study

A real‑network evaluation of four chunking configs on LobeHub's Vite SPA. Two baselines (Rolldown default codeSplitting: true and the repo's previous manualChunks), the Rolldown codeSplitting.groups recipe from the gist, and a refined "best practice" variant that fixes the recipe's lazy‑loading regression on this codebase. Companion piece to the App‑X case study; shows where the gist recipe doesn't transfer cleanly and why.

TL;DR — Four configs measured against two baselines (codeSplitting: true — Rolldown's default — and the repo's previous manualChunks). The gist's recipe as written regresses LobeHub on real networks (DCL / load +12–14% vs manualChunks, even worse vs codeSplitting: true) because per‑package vendor chunks bridge lazy‑only packages into the static entry tree via a shared vendor-common catch‑all. The fix — "best practice" below — splits every vendor group into $initial‑tagged and async halves. Best practice beats manualChunks on every real‑network metric and cuts JS request count by 38%. vendor-lobehub-charts goes from 2.63 MB eager to 0 bytes on initial, vendor-mermaid becomes truly lazy, and initial JS bytes drops from 22.48 MiB (gist recipe) to 18.97 MiB.

Worth noting: the simplest config — codeSplitting: true — still ships the fewest bytes (17.62 MiB) because it preserves every application‑defined lazy boundary. It wins on raw bytes and on real‑network DCL / load, but loses the per‑package vendor cache granularity that Layer 2 provides. See §4 and §7 for the full 4‑way picture and the cache‑vs‑bytes trade‑off.


1. Context: how LobeHub differs from App‑X

The gist reports a 63% faster "form visible" on App‑X because its esbuild output had 1,569 initial requests. Saturating HTTP/2's ~100 concurrent streams forces ~16 RTT rounds just to serialize requests — that's the waterfall Layer 1 dissolves.

LobeHub's starting point is very different:

Dimension App‑X baseline LobeHub baseline
Bundler esbuild (unbundled in dev) Vite/Rolldown
Initial JS requests 1,569 197
Initial JS transferred 16.7 MB 19.1 MiB
Chunks in dist/ 1,206
Large chunks many small 7 chunks ≥ 1 MB already
i18n strategy n/a per‑locale lazy chunks

Both apps ship ~20 MiB of JS on first nav, but LobeHub already packs it into ~200 chunks — each a sensible size, mostly parallel‑ fetchable over HTTP/2 without waterfall. The waterfall bottleneck Layer 1 targets doesn't really exist here.

LobeHub's existing manualChunks is also doing work the recipe would throw away:

  • Per‑locale i18n chunking keeps 17 locales in i18n/i18n-{locale}-*.js; only the active locale loads.
  • providerConfig, vendor-icons, vendor-es-toolkit, vendor-emotion, vendor-motion — hand‑picked for cache granularity.

2. Configs compared

Only plugins/vite/sharedRendererConfig.ts was touched. The Rollup path used for the Electron main bundle was left alone (renderer code only). Four config shapes were measured:

2a. codeSplitting: true — Rolldown default (baseline #1)

export const sharedRolldownOutput = {
  chunkFileNames: sharedChunkFileNames,
  strictExecutionOrder: true,
  codeSplitting: true,
};

No custom groups. Rolldown decides chunk boundaries on its own — one chunk per dynamic‑import boundary, plus whatever deduplication it picks up. No per‑package vendor consolidation.

2b. manualChunks — the repo's prior config (baseline #2)

export const sharedRolldownOutput = {
  chunkFileNames: sharedChunkFileNames,
  strictExecutionOrder: true,
  codeSplitting: { groups: [{ name: (id) => sharedManualChunks(id) ?? null }] },
};

sharedManualChunks does per‑locale i18n grouping (i18n-{locale}, one chunk per 17 locales), pulls model-bank into providerConfig, and hand‑picks four vendor chunks: vendor-icons (lucide), vendor-es-toolkit, vendor-emotion, vendor-motion. Also merges antd and dayjs locale files into their matching i18n-{locale} chunk.

2c. Layer 1+2 — direct translation of the gist

// Layer 2a: big packages → per-package vendor chunk
{ name: (id) => `vendor-${pkg}`, test: /[\\/]node_modules[\\/]/, minSize: 30_720, priority: 20 },
// Layer 2b: remaining small packages → single shared vendor
{ name: 'vendor-common', test: /[\\/]node_modules[\\/]/, priority: 15 },
// Layer 1: static-entry tree → ~1 MB parallel chunks
{ name: 'initial-deps', tags: ['$initial' as const], maxSize: 1_048_576, priority: 10 },

Drops manualChunks entirely; no per‑locale i18n grouping.

2d. Best practice — each vendor group split by $initial tag

// Layer 2a-initial: package modules reachable via static imports
{ name: (id) => `vendor-${pkg}`, tags: ['$initial' as const],
  test: /[\\/]node_modules[\\/]/, minSize: 30_720, priority: 26 },
// Layer 2a-async: package modules reachable only via import()
{ name: (id) => `vendor-${pkg}-async`,
  test: /[\\/]node_modules[\\/]/, minSize: 30_720, priority: 20 },
// Layer 2b-initial: small static packages
{ name: 'vendor-common-initial', tags: ['$initial' as const],
  test: /[\\/]node_modules[\\/]/, priority: 16 },
// Layer 2b-async: small async packages
{ name: 'vendor-common-async',
  test: /[\\/]node_modules[\\/]/, priority: 15 },
// Layer 1 unchanged
{ name: 'initial-deps', tags: ['$initial' as const], maxSize: 1_048_576, priority: 10 },

The tags: ['$initial'] filter on the higher‑priority group captures modules in the static entry tree; the lower‑priority unfiltered group catches the rest. Applied to both per‑package (2a) and catch‑all (2b) tiers so a package with mixed static/dynamic usage is split across two chunks instead of wholesale‑hoisted.

All the manualChunks logic for per‑locale i18n, providerConfig, vendor-icons/vendor-es-toolkit/vendor-emotion/vendor-motion was deleted from the SPA path per the integration plan.

The pnpm‑aware package‑name regex:

// Matches the *last* node_modules occurrence so pnpm's nested
// `.pnpm/pkg@ver_hash/node_modules/pkg/...` layout resolves correctly.
// Also handles scoped packages (`@scope/pkg` → `scope-pkg`).
/.*[\\/]node_modules[\\/]((?:@[^\\/]+[\\/])?[^\\/]+)/

Rolldown version: 1.0.0-rc.15+commit.772b9d9 (pinned via pnpm.overrides).


3. Methodology

Build

bun run build:spa — Vite 8.0.8 + Rolldown. Output → dist/desktop/.

The Vite/Rolldown step completes cleanly in both configs; only vite-plugin-pwa:build (closeBundle) aborts on the Layer 1+2 build because vendor-shikijs-langs (14 MB) exceeds workbox's maximumFileSizeToCacheInBytes. The dist/desktop/ output is complete and servable in both cases; only the sw.js precache manifest is blocked. We measure the dist/desktop/ outputs directly (no service worker).

Measure

  • Playwright 1.59.1 + system Chrome (Google Chrome, macOS).
  • Static http.createServer over dist/desktop/, mapping /_spa/... and / to match LobeHub's production base layout.
  • page.on('response') collects full request list; performance API collects navigation + paint timings.
  • CDP Network.emulateNetworkConditions + Emulation.setCPUThrottlingRate applied before page.goto().
  • Service workers blocked (serviceWorkers: 'block') so the PWA layer doesn't affect the measurement.
  • Five runs per (config × profile), report median.

Network profiles

Profile download upload RTT CPU
localhost unthrottled unthrottled 0 ms
broadband 50 Mbps 10 Mbps 20 ms
fast4g 9 Mbps 1.5 Mbps 170 ms

broadband matches the gist's reference condition. fast4g is Chrome DevTools' "Fast 4G" preset — representative of mid‑tier mobile users.


4. Results

All medians are from 5 runs per (config × network profile). Abbreviations: cs:true = codeSplitting: true, mc = manualChunks, l1+2 = gist recipe, bp = best practice.

Localhost

metric cs:true mc l1+2 bp
FCP 48 ms 60 ms 44 ms 40 ms
domInteractive 29 ms 34 ms 22 ms 18 ms
DCL 768 ms 838 ms 861 ms 707 ms
load 770 ms 881 ms 863 ms 709 ms
goto load 1616 ms 1856 ms 1743 ms 1569 ms
last JS resp 1842 ms 1865 ms 1976 ms 1771 ms
last res end 2477 ms 2780 ms 2695 ms 2422 ms
all requests 252 220 161 145
JS requests 229 197 138 122
JS bytes 17.62 MiB 19.07 MiB 22.48 MiB 18.97 MiB

Broadband 50/10 Mbps @ 20 ms RTT

metric cs:true mc l1+2 bp
FCP 96 ms 112 ms 104 ms 108 ms
DCL 3140 ms 3376 ms 3828 ms 3324 ms
load 3208 ms 3432 ms 3841 ms 3394 ms
goto load 4011 ms 4091 ms 4512 ms 4203 ms
last JS resp 4201 ms 4096 ms 4684 ms 4387 ms
last res end 4870 ms 5082 ms 5506 ms 5073 ms
JS requests 229 197 138 122
JS bytes 17.62 MiB 19.07 MiB 22.48 MiB 18.97 MiB

Fast 4G 9/1.5 Mbps @ 170 ms RTT + 4× CPU throttle

metric cs:true mc l1+2 bp
FCP 580 ms 744 ms 636 ms 640 ms
DCL 17146 ms 18510 ms 21069 ms 18165 ms
load 17438 ms 18754 ms 21075 ms 18444 ms
goto load 17493 ms 18833 ms 21370 ms 18531 ms
last JS resp 19553 ms 19594 ms 22890 ms 20504 ms
last res end 20226 ms 20825 ms 22952 ms 20421 ms
JS requests 229 197 138 122
JS bytes 17.62 MiB 19.07 MiB 22.48 MiB 18.97 MiB

Signal summary

codeSplitting: true — simplest config, smallest payload. No custom grouping means every dynamic‑import boundary becomes its own chunk and nothing gets hoisted. Ships the fewest JS bytes of any config tested, which translates to the fastest real‑network DCL / load on both broadband and fast4g. Highest request count (229) — harmless over HTTP/2 on a warm connection. The cost: no per‑package vendor cache granularity (every app deploy busts every vendor chunk).

manualChunks (previous repo config) — ships ~1.45 MiB more than codeSplitting: true. The hand‑picked vendor chunks (vendor-icons, vendor-es-toolkit, vendor-emotion, vendor-motion) and the aggregate per‑locale i18n chunks create the same kind of cross‑package static‑import bridging that the gist recipe suffers from — just milder. Small DCL / load regressions of 5–8% vs cs:true across all profiles.

Layer 1+2 (gist recipe, as written) — strictly worse than both baselines. FCP improves slightly (fewer chunks before first paint) but DCL / load regress 12–14% vs manualChunks on real networks. Per‑package grouping hoists vendor-lobehub-charts (2.63 MB), vendor-mermaid (1.42 MB), vendor-react-three-drei (0.97 MB), vendor-mathjs, vendor-pdfjs-dist, vendor-cytoscape, vendor-nerdamer-prime into the initial load — packages the application deliberately lazy‑loaded. Net effect: initial JS payload +4.86 MiB vs cs:true, +3.41 MiB vs manualChunks.

Best practice ($initial split) — fixes most of the regression from the gist recipe. vendor-lobehub-charts drops from 2.63 MB eager to 0 bytes initial (fully lazy). vendor-mermaid is fully lazy too. Biggest remaining initial vendor chunks are genuinely eager: vendor-lobehub-icons, vendor-lobehub-editor, vendor-antd. Localhost gains are dramatic: −33% FCP, −20% load, −38% JS requests vs manualChunks. On real networks the win vs manualChunks is modest (−1 to −2% DCL / load), and cs:true still ships ~1.35 MiB less than best practice — the remaining gap is from *-async chunks that Rolldown preloads but shouldn't.

Residual: *-async preload leaks. 8 chunks totaling ~2 MiB named vendor-<pkg>-async still fetch on initial nav because vendor-common-initial or initial-deps-* statically imports from them — Rolldown's $initial tag doesn't always propagate across cross‑package static imports. Worth filing upstream. Cost is bounded (best practice still ships less than manualChunks and way less than the gist recipe), but closing the gap would bring best practice on par with cs:true on bytes while keeping the per‑package cache granularity.


5. Chunk anatomy — why bytes grew

manualChunks (baseline #2) — 197 JS requests, 19.07 MiB on the wire

by dir   files   bytes
assets   189     15.65 MiB
i18n       4      3.07 MiB     ← only default + active locale
vendor     4      0.35 MiB     ← hand-picked

Top 5 chunks actually fetched:

3170 KB  assets/es-DzHgNOWe.js               (language grammar pack)
2690 KB  assets/es-Ds4M8UqE.js               (language grammar pack)
1606 KB  i18n/i18n-default-iDmgnRoX.js       (default locale)
1463 KB  assets/es-i4ODA2iz.js
1433 KB  assets/selectors-RvHKn4Ah.js        (zustand selectors)

Critically, the i18n/ directory sends only 4 files on first load (default + zh‑CN + en‑US + ar). LobeHub's curated per‑locale splitting keeps the other 14 locales behind a lazy boundary.

Layer 1+2 — 138 JS requests, 22.48 MiB on the wire

by dir   files   bytes
vendor    99     18.54 MiB    ← per-package split
assets    39      3.94 MiB    ← initial-deps + lazy route chunks

initial-deps-* (Layer 1 output): 13 files, 3.44 MiB — the static app tree collapsed into ~1 MB parallel chunks, exactly as the recipe intends.

Top 15 chunks actually fetched:

3173 KB  vendor/vendor-lobehub-icons-*.js          ← @lobehub/icons
2633 KB  vendor/vendor-lobehub-charts-*.js         ← @lobehub/charts, was lazy
1616 KB  vendor/vendor-ant-design-pro-card-*.js
1420 KB  vendor/vendor-mermaid-*.js                ← mermaid, was lazy
 969 KB  vendor/vendor-react-three-drei-*.js       ← three/drei, was lazy
 795 KB  assets/initial-deps-CP8-5EvU.js
 636 KB  vendor/vendor-mathjs-*.js                 ← mathjs, was lazy
 583 KB  assets/initial-deps-DBaBl95d.js
 542 KB  assets/initial-deps-DGGsrxos.js
 516 KB  vendor/vendor-lobehub-editor-*.js
 425 KB  vendor/vendor-cytoscape-*.js              ← was lazy
 424 KB  assets/initial-deps-CA-mFYhW.js
 419 KB  vendor/vendor-emoji-mart-data-*.js
 409 KB  vendor/vendor-nerdamer-prime-*.js         ← was lazy
 393 KB  vendor/vendor-pdfjs-dist-*.js             ← was lazy

Seven of the top fifteen chunks are npm packages that were lazy in the manualChunks baseline but are now eagerly fetched (mermaid, react-three-drei, mathjs, cytoscape, nerdamer-prime, pdfjs-dist, charts). Together they account for the bulk of the +3.41 MiB regression.

The actual mechanism — vendor-common as a static‑import bridge

Tracing Layer 1+2's initial‑deps chunks, the static import chain is:

entry
  → initial-deps-*.js              (Layer 1 output, $initial tree)
    → vendor-common-*.js           (Layer 2b catch-all for small packages)
      → vendor-lobehub-charts-*.js (Layer 2a per-package)
      → vendor-react-three-drei-*.js
      → vendor-mermaid-*.js
      → vendor-mathjs-*.js
      → …

All edges are import … from …, not import(…). vendor-common is the culprit: it aggregates small npm packages (those below minSize: 30_720) into one chunk. At least one of those small packages has a static sub‑dependency into @lobehub/charts (and other "lazy" packages). Because vendor-common contains modules from both the static tree and the async tree, and because consolidating modules into a named chunk forces the chunk to load as a unit, the chunk becomes eager — and carries all of its static imports (including to vendor-lobehub-charts) eager along with it.

In codeSplitting: true and manualChunks there was no vendor-common node. Each small package either lived next to its importer (small → inlined) or got its own tiny chunk. The cross‑package static sub‑dependency still existed, but the chunks at either end of it were both small and route‑scoped, so they stayed behind the lazy boundary. Consolidation created the bridge.

The per‑package Layer 2a group has a related failure mode: @lobehub/charts has one module used from a static dep chain (the cross‑package import above) and the rest used only from lazy routes. Grouping puts them all in one chunk → the chunk becomes eager → the entire 2.63 MB package loads on init even though only the single entry‑tree symbol is actually needed statically.

vendor-shikijs-langs (14 MB on disk) escapes this fate and doesn't load on initial nav — but does break the PWA precache because workbox's default maximumFileSizeToCacheInBytes is exceeded by a single file.

The best‑practice fix: split every vendor group by $initial tag

Adding a tags: ['$initial'] filter to the higher‑priority twin of each vendor group, and letting the unfiltered lower‑priority group catch the rest, produces separate -initial and -async chunks. The bridge edge from vendor-common-initial now points to vendor-<pkg> for the statically‑reached subset of each package, not to the full vendor-<pkg> carrying the lazy parts.

Empirically, this collapsed vendor-lobehub-charts from 2.63 MB eager to 0 bytes on initial (fully lazy), and vendor-mermaid from 1.42 MB eager to 0 bytes. Total initial JS bytes dropped from 22.48 MiB → 18.97 MiB. See §4 for the full table.

A residual: 8 *-async chunks (~2 MiB total) still fetch on initial nav because Rolldown's $initial tag propagation across cross‑package static imports appears inexact — some modules that ARE statically reachable from initial-deps get placed in the -async chunk, and the chunk's preload hint pulls them in. This is worth filing upstream; closing it would bring best practice's byte total to parity with codeSplitting: true while retaining per‑package cache granularity.


6. Why the unmodified recipe doesn't transfer cleanly

The gist's 63% win on App‑X comes from two effects in sequence:

  1. The esbuild output had a waterfall with 1,569 initial requests hitting HTTP/2's concurrent‑stream cap. Layer 1 collapses them into ~22 parallel ~1 MB chunks — latency wins dominate.
  2. Per‑package vendor isolation was additional, smaller gain.

Neither effect is load‑bearing for LobeHub as written:

  1. No waterfall to collapse. LobeHub's codeSplitting: true output is already 229 parallel HTTP/2 streams, manualChunks is 197. Neither saturates the concurrent‑stream cap, so Layer 1's request‑count reduction doesn't translate to a latency win.
  2. Per‑package vendor without $initial scoping is a regression. Unlike App‑X's code, LobeHub has substantial package‑level lazy loading. Per‑package grouping without tag scoping hoists entire packages eager via the vendor-commonvendor-<pkg> bridge.

The recipe's "Layer 2" section implicitly assumes every node_modules module is either used from the entry tree only, or used from a lazy route only. That assumption holds for apps with thin vendor usage. It breaks for apps like LobeHub whose deeper pages (stats dashboards, math/code/pdf/graph/3D viewers) ship heavy single‑purpose libraries behind lazy routes.

The $initial tag split turns the recipe from "lift and shift" into "apply with care": we keep Layer 1's request‑count reduction, we keep per‑package cache granularity, but we no longer break the application's lazy boundaries.


7. Tradeoffs & recommendations

Concern cs:true manualChunks layer1+2 best practice
Initial JS bytes (broadband) 17.62 MiB 19.07 MiB 22.48 MiB 18.97 MiB
Initial JS requests 229 197 138 122
DCL (broadband) 3140 ms 3376 ms 3828 ms 3324 ms
DCL (fast4g) 17146 ms 18510 ms 21069 ms 18165 ms
Lazy‑loading integrity ✅ kept ✅ kept ❌ broken ✅ kept
Per‑locale i18n split implicit ✅ curated ❌ gone ❌ gone
Per‑package vendor caching partial (hand‑picked) ✅ full ✅ full
PWA precache ✅ builds ✅ builds ❌ 14MB blocker ❌ 14MB blocker

Cache invalidation — the decisive factor

A 1‑character edit to a leaf component (src/features/AgentSelectionEmpty.tsx: maxWidth: 400 → 401), rebuilding under each config, and diffing the chunk manifests:

metric cs:true best practice
total chunks in build 2,017 1,198
chunks with new hash 249 210
chunks still cacheable 1,768 988
bytes to re‑download 9.69 MiB 5.56 MiB
% of build rewritten 13.5% 8.1%
vendor chunks invalidated N/A (none named) 0 / 116

cs:true mixes vendor code with app code in content‑hashed chunks. Even React's chunk (React-*.js, 595 KB) gets a new hash after a pure app edit because the chunk contains downstream app code. Best practice keeps all 116 vendor-* chunks byte‑identical across app‑only deploys — ~18 MiB of third‑party code is paid for once and amortized.

User‑lifetime cost model for a user who returns after N app‑only deploys:

                first visit     per deploy re‑download    total after N deploys
cs:true         17.62 MiB           9.69 MiB              17.62 + 9.69N
best practice   18.97 MiB           5.56 MiB              18.97 + 5.56N

Break‑even at N ≈ 0.33 — best practice is cheaper starting at the very first deploy a returning user catches. Only a single‑visit‑ever user benefits from cs:true's smaller cold load.

scenario winner
one‑time visitor, no cache cs:true (−1.35 MiB cold)
daily user, catches weekly deploys best practice (−4.13 MiB/deploy)
deep‑route user (stats/pdf/mermaid) + caching best practice

Recommendation

Ship best practice (§2d). It wins for LobeHub's dominant use case (daily active users on canary with frequent deploys). The ~1.35 MiB cold‑load premium vs cs:true pays back at the first deploy; per‑package vendor caching saves ~4.13 MiB of re‑download on every subsequent visit. Before merging:

  1. Raise workbox.maximumFileSizeToCacheInBytes to ≥ 15 MB or add maxSize on Layer 2a so vendor-shikijs-langs breaks into pieces — needed to un‑break PWA precache.
  2. Optionally re‑introduce per‑locale i18n grouping on top of the vendor split. manualChunks kept 17 locales behind a lazy boundary with one chunk each; best practice throws that away. Add it back as a higher‑priority named group before Layer 2a.
  3. File a Rolldown issue for the 8 *-async chunks (~2 MiB) that leak into the initial load despite carrying only non‑$initial modules. Closing the leak would bring best practice within ~0.1 MiB of cs:true on cold load.

The gist recipe as written (layer1+2, without the $initial tag split) should not ship.


8. Reproducing

# From the lobehub checkout on branch chore/vite8-upgrade. Swap the
# `sharedRolldownOutput` export in plugins/vite/sharedRendererConfig.ts
# between the four shapes defined in §2. Rebuild + measure each:

rm -rf dist/desktop && bun run build:spa
#   PWA step fails on layer1+2 / best practice builds (shikijs-langs
#   14 MB exceeds workbox default). dist/desktop is still complete.

node /tmp/lobehub-perf/measure-net.mjs \
  dist/desktop <label> <port> {localhost|broadband|fast4g}

Measurement scripts:

  • /tmp/lobehub-perf/measure-net.mjs — single run, CDP throttled
  • /tmp/lobehub-perf/multi-run-net.sh — N‑run harness
  • /tmp/lobehub-perf/compare-net.mjs — 2‑way median‑delta formatter
  • /tmp/lobehub-perf/compare-4way.mjs — 4‑way table used here

Run artifacts live in /tmp/lobehub-perf/runs/*.json (60 summary JSONs across 4 configs × 3 network profiles × 5 runs, plus per‑run request lists for deep dives).

Environment: macOS (Darwin 25.3.0), Google Chrome (system), Playwright 1.59.1, Node 24.14.1, Vite 8.0.8, Rolldown 1.0.0-rc.15+commit.772b9d9.

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