docs: measure the runtime and the generated client modules
Quality / quality (ubuntu-latest) (push) Failing after 13m21s
Quality / quality (windows-latest) (push) Canceled after 0s

Adds a per-subsystem measurement of reactive.js, made by minifying it
repeatedly with one subsystem removed rather than counting source bytes.

This corrects the earlier audit on both figures and on the conclusion drawn
from them. Component controllers are 23,722 bytes minified / 6,660 gzipped --
30.6% of transfer, not the "about 18%" previously claimed -- and splitting them
out saves 6.6 kB gzipped on a typical page, not "3-4 kB". Measured against the
example app, / and /login use none of the ten controllers and /layout uses one,
so most pages download and parse the lot for nothing.

The larger finding is that the runtime is not where the weight is. One page
parses 490,212 decoded bytes across 11 generated client modules while
transferring 21,026, and the largest module is 89.8% duplicated lines: the
state-restore prologue appears 162 times because client-codegen.ts inlines the
sync into every peer alias of every client function. Gzip hides it on the wire,
but parse cost follows decoded bytes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-09 10:02:36 +05:30
co-authored by Claude Opus 5
parent 30d1632252
commit 5112cc1a62
20 changed files with 643 additions and 5 deletions
+142
View File
@@ -466,6 +466,148 @@ confusing ways.
---
## 4.6 Runtime and client-module size — measured
A per-subsystem measurement of `reactive.js` and of the generated client
modules, made by minifying the runtime repeatedly with one subsystem removed
each time. Source-byte share was not used: it overstates code that minifies
well and understates code that does not, and the split/keep decision turns on
the real number.
**This corrects the earlier audit**, which claimed component controllers were
"about 18%" of the runtime and concluded that splitting saves "3-4 kB gzipped".
Both figures were wrong, and the conclusion that followed from them was wrong.
### The runtime today
`reactive.js` is **70,101 bytes minified, 21,736 gzipped**. Removing each
subsystem and re-minifying gives its true cost:
| Subsystem | Minified | Share |
| ----------------- | -------- | ----- |
| Select/combobox | 7,852 | 11.2% |
| PinInput | 3,734 | 5.3% |
| async boundaries | 3,151 | 4.5% |
| Navbar | 2,419 | 3.5% |
| splitters | 2,083 | 3.0% |
| roving focus | 1,646 | 2.3% |
| modal dialogs | 1,611 | 2.3% |
| csr fetch | 1,547 | 2.2% |
| anchored overlays | 1,419 | 2.0% |
| scrollspy | 1,373 | 2.0% |
| preferences | 1,045 | 1.5% |
| toast | 534 | 0.8% |
Component-specific controllers (everything except async boundaries and csr
fetch, which are framework features) total **23,722 minified / 6,660 gzipped —
30.6% of what a visitor downloads.** The irreducible core is 46,379 minified /
15,076 gzipped: the expression engine, the scope and reactivity core, and loop
diffing.
### How much of it a page actually uses
Measured against the example app by checking which controller markers appear in
the served HTML:
| Page | Controllers used |
| ------------- | ------------------ |
| `/` | **0 of 10** |
| `/login` | **0 of 10** |
| `/layout` | 1 of 10 (splitter) |
| `/navigation` | 5 of 10 |
A typical page downloads and parses 6.6 kB gzipped of controller code it never
executes. The Select controller — the single largest item at 7,852 bytes — is
used by none of the pages above.
**Change.** Split the component controllers out of the core runtime and load
them on demand, keyed on the marker attribute that already gates each one
(`data-wrn-select`, `data-wrn-splitter`, `data-wrn-scrollspy` and so on). The
gating logic exists; only the loading boundary is missing. Keep the core
runtime as one immutable-cached file.
**How to test.** Assert the core bundle size, and per page assert that a
controller chunk is requested only when its marker is present:
```bash
bun run scripts/lib/measure-runtime-size.ts # core must stay under budget
```
Plus a browser check on `/`: zero controller chunks requested.
### The bigger problem: generated client modules
The runtime is not where the weight is. On `/navigation`:
- **490,212 bytes decoded** across 11 client modules, **21,026 transferred**
a 23:1 compression ratio.
- The largest single module is **269,117 bytes** decoded, of which **89.8% is
duplicated lines**.
- One line appears **162 times**: `brand = context.state.brand; topLinks =
context.state.topLinks; ...` — the full state-restore prologue.
Gzip hides this on the wire, but **parse and compile cost scales with decoded
bytes, not transferred bytes**. Half a megabyte of JavaScript is parsed to run
one page.
**Cause.** `packages/compiler/src/client-codegen.ts:228-264` inlines the state
sync into _every peer-function alias, in every client function_. Each alias
emits `syncStateToContext` once and `syncStateFromContext` three times — the
catch path, the promise `finally`, and the synchronous path. The output is
O(functions x peers x state variables). With ~19 state variables and 81 peer
aliases in that module, that is several thousand generated assignments.
**Change.** Hoist the sync out of the per-alias wrapper. The cheapest version
with no change to how bodies are written: emit **one** pair of closures per
client function and have every peer alias call them, instead of inlining the
sync per alias:
```js
const __flush = () => {
context.state.brand = brand; /* ... */
};
const __restore = () => {
brand = context.state.brand; /* ... */
};
const __peer =
(name) =>
(...args) => {
__flush();
let r;
try {
r = context.functions[name](...args);
} catch (e) {
__restore();
throw e;
}
if (r && typeof r.then === "function") return Promise.resolve(r).finally(__restore);
__restore();
return r;
};
const doThing = __peer("doThing");
```
That removes the peer multiplier — the dominant factor — and takes the 81
copies down to roughly one per function. It is a codegen change only, with no
change to semantics or to how anyone writes a component.
A larger follow-up, if the first is not enough: keep state in a single object
and rewrite state identifiers in the body to reference it, which removes the
per-variable multiplier as well. That one needs the body transform and should
be measured before it is attempted.
**How to test.** Pin decoded size, because gzip hides regressions here:
```bash
bun run build
# assert the largest generated client module is under budget, DECODED not gzipped
```
Add the ratio itself as a signal: any module compressing better than about 10:1
is duplicating itself and should fail the check. Existing behaviour is covered
by the current suite, so correctness is the 1,427 tests; this is purely a size
assertion on top.
## 5. Order of work
Ranked by return, not by size. The first item changes the cost of every item