| name | rspeedy-bundle-size |
| description | Analyze and reduce the bundle size of rspeedy/Lynx (ReactLynx) apps โ the size / `.lynx.bundle` lever (not runtime perf, types, or CI). Use when the user wants to optimize ๅ
ไฝ็งฏ / bundle size, find what is making a rspeedy bundle large, decide which size lever to pull, or shrink `main-thread.js` by stripping background-only code (jsb / telemetry / request / monitor SDKs) out of the render path via `__BACKGROUND__` / `'background only'` gating. Covers media, JS-background, and main-thread layers, plus mangle/eval-poisoning bloat, i18n-locale duplication, duplicate-package dedup, extractStr/compile knobs, and lazy/dynamic-component splitting (first-screen vs total size). Measure-first and evidence-based โ produces a prioritized report; changes code only when asked. |
Rspeedy Bundle Optimization
A measure-first workflow for shrinking the bundle of a rspeedy/Lynx app. The central discipline: don't guess where the bytes are โ measure, then attack the biggest lever first. Most "optimize the bundle" requests fail by starting with micro-tree-shaking when the real weight is media assets or main-thread leakage.
Verify against source โ do not guess
The rspeedy / ReactLynx build behaves in non-obvious ways, and guessing is how you end up confidently wrong. When you're unsure how the build, the layers, the ES targets, or stats/rsdoctor output actually behave, read the source โ don't infer from naming or memory. The whole toolchain is open source; clone or browse it and grep for the relevant plugin/util:
The toolchain layers as Rspack โ Rsbuild โ Rspeedy (each builds on the one before), so a behavior may live in any layer โ follow it down:
(Exact file paths aren't pinned here โ they move between versions. Search by symbol, e.g. the ES-target util, the stats-json plugin, the entry/layer setup.) Several facts in this skill โ main=ES2019 / background=ES2015, stats.json carrying layer, the entryโtwo-layer split โ were each confirmed by reading the source or running a real build, not assumed. Hold new claims to the same bar.
NO GUESS โ trace the real module graph, never narrate an import chain
When the question is "why is X bundled / who imports Y / what's the chain from the entry to Z", you must trace the actual module graph โ never describe a plausible chain from package.json, intuition, or a half-answer like "it comes in via the dependency tree, scope-hoisted into ./entry.tsx + 4630 modules." That + N modules is the bundler hiding the edge from you, not an answer. To get the real edges:
- Build with
tools.rspack setting optimization.moduleIds: 'named' AND optimization.concatenateModules: false. Scope-hoisting (concatenateModules: true, the prod default) collapses importer edges โ every hoisted module's importer is reported as the whole concatenated group ("./entry.tsx + N modules"), so you literally cannot see who imports what. Disabling it is mandatory for chain tracing (it inflates size substantially โ ~+70% observed on one project, varies by module graph โ so it's analysis-only; revert after). De-concat also disables __split__, so components already split off the main thread reappear inflated in main-thread.js โ cross-check any "huge popup/modal" against the real build before calling it a lazy-load candidate (see rspeedy-gotchas.md ยง11).
- Read the actual importer edges from
stats.json's reasons[].moduleName (each module lists the modules that import it). On a big multi-entry build this file can reach hundreds of MB to ~1 GB โ too big for readFileSync (ERR_STRING_TOO_LONG); stream it (grep / readline). (A single-page build with the default stats preset is far smaller โ only stats: { all: true } or a many-page merge blows it up; see measure-with-rsdoctor.md.)
- Walk the chain hop by hop and report the exact file at each hop. If you can't back a hop with a real reason edge, you don't have the chain โ say so, don't invent it.
Real example this rule comes from: an app's 1388-icon bloat looked like "app โ business component lib โ UI lib โ icon barrel" โ but the app imported none of those packages directly (0 hits in src/); only the de-concat reasons revealed the true edges. A narrated chain would have pointed the owner at the wrong file. A dependency chain without real reason-edges behind it is a guess; do not present guesses as chains.
When to use this skill
- "ๅธฎๆไผๅไธไธๅ
ไฝ็งฏ" / "reduce the bundle size of this rspeedy app"
- "ไธบไปไนๆ็ๅ
่ฟไนๅคง" / "what is making this bundle large"
- After a build, the user wants the size broken down and a prioritized plan
- Deciding whether tree-shaking is even worth it, vs media compression vs main-thread cleanup
A distinct cause of an oversized bundle is mangle failure โ readable module-prefixed names (common_EVENT_NAME, utils_toSomething) surviving in the production chunk, usually from eval() poisoning under ModuleConcatenation. It's covered as part of the JS lever in references/three-levers.md ยงLever 2.
When NOT to use this skill
This is the bundle-size / .lynx.bundle lever only โ it optimizes shipped bytes, not anything else. Route elsewhere for:
- Runtime / render performance (jank, slow first paint, frame drops) โ a different concern; this skill doesn't touch execution speed.
- JS memory / heap leaks โ use
lynx-js-heap-snapshot-analysis.
- Directive semantics (
'background only' accepted forms / behavior) โ defer to reactlynx-best-practices; this skill only applies the directive as a size lever.
- Deep
rsdoctor-data.json forensics (per-module retained-set, duplicate trees) โ use rsdoctor-analysis; this skill only summarizes it.
- TS types / tsconfig โ
lynx-typescript. Decoding the tasm binary format โ lynx-tasm-codec.
Related skills
If you have access to them, they go deeper on adjacent problems:
Build-system awareness
Before measuring, identify the repo's build orchestrator and use its native command surface rather than assuming pnpm build:
- Rush monorepo โ if
rush.json or common/config/rush/ exists, use the Rush skill. Run project scripts with rushx <script> from the project dir, or scoped repo commands such as rush build --to .; use rush-pnpm only when a pnpm command is truly necessary.
- Custom wrapper tooling โ if scripts or config wrap
rspeedy behind a company/monorepo CLI, prefer that documented entrypoint over calling rspeedy/pnpm directly.
- Plain pnpm/npm project โ fall back to
pnpm / npm directly when the repo is not Rush-managed and has no wrapper.
Do not blindly run pnpm build. First read package.json, root workspace files, and any existing project docs; then choose the narrowest command that builds the target rspeedy app while preserving the repo's environment setup. If the project pins Node, use that version via your Node version manager (e.g. fnm exec --using=<version> -- ... or nvm exec <version> ...).
Triage first when you have many projects (don't build a whole fleet)
The per-project workflow below is measure-first โ but if you're handed a fleet ("optimize our Lynx bundles", a dashboard of 50+ apps), you can't build them all to decide where to look. Run a cheap source-level triage to rank which projects/levers deserve a real build, then measure-first the winners. A triage hit means "measure here", not "confirmed bloat".
The scanner references/assets/scan-levers.mjs walks each project's source (no build) and ranks by unguarded background-only leak, missing sideEffects, inline/large media, and duplicate deps:
node references/assets/scan-levers.mjs --glob <reposRoot>
node references/assets/scan-levers.mjs <projectDir> --json out.json
Read references/cross-project-triage.md for how to interpret it and its traps (duplicate-dep counts are dominated by the dev toolchain and mostly noise; source presence โ main-thread presence; a telemetry signature over-counts a property name). Empirically across a real ~370-project fleet: ~74% have background-only leak signal in source, ~62% mostly unguarded, ~99% miss sideEffects โ telemetry + jsb/native glue is referenced almost everywhere and almost always unguarded.
Telemetry/logging is background-only only when it's jsb-backed โ a console-backed logger (console.*) runs on the main thread too, so confirm the underlying impl before treating a telemetry call as strippable. When it is jsb-backed it's a broadly-relevant Lever-3 signal โ but measure before acting on it. The blind strip (plugin-strip-mt-telemetry.ts + loader, method list customizable) netted only โ3.9 kB (โ0.08%) on a real project because the logger imports survived (the last-reference rule), so treat it as a measurement aid, not the fix (see three-levers.md ยงLever 3) โ not a "drop it in and win" lever. By contrast one config line โ extractStr: true โ netted โ1.88% to โ5.99% across a 6-project fleet round (up to โ1.29 MB on a 64-page string-heavy app; ~0 on thin demos or apps with little duplicated string content). So on a string-heavy app not already using extractStr, just turning it on is the highest-ROI first move โ far above hand-chasing telemetry leaks. (Just set extractStr: true; tuning the strLength threshold is generally not recommended.)
The mental model: three layers, three levers
A Lynx app bundle splits across two threads, plus non-JS assets. Size lives in three places, and the optimization technique is different for each โ confusing them is the most common mistake.
| Lever | What it is | Typical size | Who can fix it |
|---|
| Media assets | images/fonts inlined or shipped | often the single biggest chunk (multi-MB) | app code โ usually highest ROI |
| JS โ background thread | react:background layer, app logic | varies | app code โ tree-shaking, dedup |
| JS โ main thread | react:main-thread layer, first-screen render path | should be small; leaks happen | trace โ module split (markers โ 0); big chains are library asks |
See references/three-levers.md for how to size and attack each.
Read references/dual-thread-architecture.md before optimizing if you don't already know why there are two JS layers โ it has the full model and the ไปฃ็ ่ฃๅช macro table that every Lever-3 fix relies on. In short: the main thread does first-screen direct render and must stay lean; the background thread carries logic / lifecycle / state. The build emits a main-thread.js + background.js, compiled into the binary .lynx.bundle the engine ships. ReactLynx auto-shakes useEffect/componentDidMount/bindtap callbacks out of main-thread.js; when it can't decide, steer it with 'background only' or the __BACKGROUND__/__MAIN_THREAD__ macros โ that mechanism is the main-thread-leakage lever.
Core workflow
Step 1 โ Measure (never skip)
Get a real per-module breakdown before proposing anything. This holds even when the user already hands you rough sizes (e.g. "images ~10MB, JS ~4MB") or asks you to just do a specific change โ a glance at output sizes is a starting hypothesis, not a per-module measurement. Always confirm the breakdown with rsdoctor (or stats.json) and lead with the prioritized recommendation before committing to โ or implementing โ any lever, including a change the user explicitly requested. If the asked-for change is the wrong (low-ROI) lever, say so first; only proceed with it after the measured breakdown confirms it's worth it.
The official way for rspeedy is rsdoctor via config โ do not manually install @rsdoctor/rspack-plugin, and do not reverse-engineer internal forks. Add to lynx.config.ts:
tools: {
rsdoctor: {
disableClientServer: true,
brief: { writeDataJson: true },
},
}
Then build with RSDOCTOR=true. Full details, allowed config fields, and the sharded-data reconstruction trick are in references/measure-with-rsdoctor.md.
Use the build-system-aware command from the previous section:
RSDOCTOR=true rushx build
RSDOCTOR=true <wrapper> build
RSDOCTOR=true pnpm build
A lighter alternative for a quick first look is rspeedy's built-in stats.json โ turn it on with performance.profile: true in the config, or just build with DEBUG=rspeedy (it writes output/stats.json). It does carry a layer field per module (react:main-thread / react:background, verified on the demo), so it can separate the two threads. Prefer rsdoctor when you want per-module gzip sizes and duplicate/tree-shaking analysis for free; reach for stats.json for a fast layer-vs-size split with zero extra setup. Details in references/measure-with-rsdoctor.md.
Step 2 โ Classify the weight into the three layers
From the rsdoctor moduleGraph, group module.size.parsedSize (and gzipSize) by:
- Media / assets โ non-JS modules, or asset modules
- JS
react:background โ module.layer contains background
- JS
react:main-thread โ module.layer contains main-thread
Report the three totals first. That number alone usually decides the whole engagement โ e.g. "10MB of your 14MB is images; JS tree-shaking can win you ~50KB, so start with media."
Step 3 โ Pick the lever by ROI, attack in order
Default priority (override if the numbers say otherwise):
1. Media assets โ biggest, easiest wins (compress, dedup, format)
2. JS background โ tree-shaking, duplicate packages, deep imports
3. Main-thread leak โ background-only code that leaked into render path
4. Compile-layer knobs โ extractStr, CSS minify, debug-info, lazy bundle โ often beats the JS tail
For each lever's concrete techniques and decision points, see references/three-levers.md (Levers 1โ4).
Step 4 โ Know when to stop
A well-optimized project hits a wall: tree-shaking is effectively exhausted (sideEffects set correctly โ or, since ~99% of fleet projects omit the flag, the minifier already did the equivalent DCE), code-level DCE yields ~0 at the bundle level, and the remaining main-thread leak lives inside SDK libraries you can't edit. Say so plainly. Don't manufacture marginal changes to look busy โ report the wall and what would move it (a library-side 'background only' directive, an asset pipeline change in CI, etc.).
The structural-duplication wall (the most common shape on big pages โ verified by deep-diving two real big apps). A large .lynx.bundle is usually dominated not by leaks or dead code but by a heavy resource the first-screen direct render genuinely needs, carried in BOTH layers (main-thread and background each bundle their own copy under the dual-thread model). Two real examples: one app's page was 87% i18n locale JSON (all 57 languages, in both layers); another was 62% a Pixi-like render engine (~1.4 MB in each layer) that draws the first screen. When you see this, the usual app-side levers all fail by construction, and saying why is the deliverable:
- Can't lazy-split it โ it's first-screen-direct-render content; deferring it makes the first screen load a chunk mid-render (slower / FOUC). (This is the lazy-bundle rule applied: never lazy what first-screen needs.)
- Strip/DCE/dedup don't apply โ it's neither dead code nor a duplicate package; it's the same needed resource legitimately present on two threads.
extractStr only helps the string-shaped case โ it dedups duplicated string literals across the two layers (i18n locale text, class names โ that's why string-heavy apps shrink a lot under it), but it does not dedup duplicated code (a render engine), so it won't touch the engine case.
So the real lever for these is library/framework-level, not app-side: a lighter library, a framework feature to share code across the two threads, or a host that injects the resource (e.g. the active locale) synchronously so the bundle needn't carry it. Measure the dominant chunk, name it, say which of these it needs โ don't grind app-side config for a page whose weight is structural.
Gotchas specific to rspeedy/Lynx
Several things will bite you if you treat this like a generic webpack project. Read references/rspeedy-gotchas.md before editing config or asserting a finding. The headline traps:
- The two threads ship at fixed ES targets โ main-thread = ES2019, background = ES2015 (prod); both lower
let/constโvar for parse speed. These are engine-driven platform constants; don't propose retargeting to save bytes. (Some projects pin output.environment even lower, to full ES5, for parse time โ a deliberate tradeoff, not the norm.)
- rsdoctor data may be sharded (
.rsdoctor/ dir, not a single JSON) โ reconstruct correctly or your numbers are wrong.
- builds need the pinned Node (via fnm/nvm, e.g.
fnm exec --using=20.19.0 or nvm exec 20.19.0) โ newer Node can break rspeedy config loading.
Logging convention โ every optimization gets a .bundle-opt-log/ entry
Required for any optimization (analysis or applied): write a log entry under a hidden .bundle-opt-log/ directory at the root of the project being optimized (e.g. apps/<app>/.bundle-opt-log/). The log's primary metric is .lynx.bundle size before โ after (the shipped binary), per page.
Each entry (one Markdown file per round, named by date + lever, e.g. 2026-06-04-icon-treeshake.md) records:
- project / page, and
.lynx.bundle before and after (kB), with the ฮ and %
- the lever applied (one line), and whether it's applied / proposed / wall
- the verification build command (so the number is reproducible), and the Node version if pinned
- the measurement caveat if any (e.g. warm-cache vs cold โ see gotchas ยง7; a delta under ~3% needs a warm re-measure)
Also maintain ONE consolidated tracking table (e.g. .bundle-opt-log/ไผๅ่ฎฐๅฝ่กจ.md / SUMMARY.md): a single Markdown table with one row per optimization โ ไผๅ้กน | .lynx.bundle ๅ | ๅ | ฮ | ๆบๅถ | commit โ plus a cumulative total row, and a second table for investigated-but-not-shipped levers (with their measured ฮ, often ~0, and the reason). Append a row every time you land an optimization; chain the before/after so each row's "before" = previous row's "after". This is the at-a-glance ledger; the per-round files hold the detail. Owners specifically ask for this table โ keep it current.
Keep it .lynx.bundle-centric: intermediate main-thread.js/background.js numbers are supporting detail, but the headline is always the shipped .lynx.bundle before/after. Put build artifacts (intermediates, analysis de-concat dumps) under the gitignored build output dir (output/ or dist/), never in a new top-level dir โ only the .bundle-opt-log/ markdown is meant to live in the repo. Confirm with the owner whether .bundle-opt-log/ should be committed or gitignored before creating it.
Stance: report-first, read-only by default
Like the other analysis skills here, default to producing a prioritized report, not editing code. Present findings + options + tradeoffs; let the user choose what to change. Only modify app code when explicitly asked, and keep changes surgical.
Response format
- Measured breakdown โ the three-layer totals (media / bg JS / main JS), biggest modules in each
- Biggest lever โ which layer dominates and why
- Prioritized actions โ table of action โ est. savings โ effort โ who can do it (app vs library vs CI)
- Main-thread leakage trace โ if any leak exists, include the suspicious module plus its upstream importer path / owner; don't only report the leaked API string
- Compile-layer quick wins โ
extractStr / CSS minify / debug-info / lazy-bundle knobs tried or proposed (Lever 4)
- The wall โ what's already optimal and what external change would be needed to go further
- Log entry โ write the
.bundle-opt-log/ entry and append to the tracking table (see Logging convention)