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.
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.
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:
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.
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.
// 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.
// 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).
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).
- Playwright 1.59.1 + system Chrome (Google Chrome, macOS).
- Static
http.createServeroverdist/desktop/, mapping/_spa/...and/to match LobeHub's productionbaselayout. page.on('response')collects full request list;performanceAPI collects navigation + paint timings.- CDP
Network.emulateNetworkConditions+Emulation.setCPUThrottlingRateapplied beforepage.goto(). - Service workers blocked (
serviceWorkers: 'block') so the PWA layer doesn't affect the measurement. - Five runs per (config × profile), report median.
| Profile | download | upload | RTT | CPU |
|---|---|---|---|---|
| localhost | unthrottled | unthrottled | 0 ms | 1× |
| broadband | 50 Mbps | 10 Mbps | 20 ms | 1× |
| fast4g | 9 Mbps | 1.5 Mbps | 170 ms | 4× |
broadband matches the gist's reference condition. fast4g is
Chrome DevTools' "Fast 4G" preset — representative of mid‑tier
mobile users.
All medians are from 5 runs per (config × network profile).
Abbreviations: cs:true = codeSplitting: true,
mc = manualChunks, l1+2 = gist recipe, bp = best practice.
| 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 |
| 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 |
| 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 |
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.
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.
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.
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 defaultmaximumFileSizeToCacheInBytesis exceeded by a single file.
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.
The gist's 63% win on App‑X comes from two effects in sequence:
- 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.
- Per‑package vendor isolation was additional, smaller gain.
Neither effect is load‑bearing for LobeHub as written:
- No waterfall to collapse. LobeHub's
codeSplitting: trueoutput is already 229 parallel HTTP/2 streams,manualChunksis 197. Neither saturates the concurrent‑stream cap, so Layer 1's request‑count reduction doesn't translate to a latency win. - Per‑package vendor without
$initialscoping 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 thevendor-common→vendor-<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.
| 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 |
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 |
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:
- Raise
workbox.maximumFileSizeToCacheInBytesto ≥ 15 MB or addmaxSizeon Layer 2a sovendor-shikijs-langsbreaks into pieces — needed to un‑break PWA precache. - Optionally re‑introduce per‑locale i18n grouping on top of
the vendor split.
manualChunkskept 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. - File a Rolldown issue for the 8
*-asyncchunks (~2 MiB) that leak into the initial load despite carrying only non‑$initialmodules. 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.
# 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.