Performance
Fifteen levers: the JavaScript runtime at 16 KB
What a hello-world page loads up front of @undra/runtime went from 22,100 to 15,774 bytes gzipped, under a 16,000-byte budget. No feature was removed: code a hello page does not use loads when something first needs it, and a production page reports an error as a code with a link instead of a sentence. Here are the fifteen levers, and the four defects the review caught.
Where the bytes were
The web size decision (ADR-052) asked for 16 KB of runtime up front. An earlier piece reached 21.2 KB and concluded that 16 KB would mean removing behaviour; five pieces then added 0.9 KB, and the gate stood at 22,100 bytes with no room. Instead we attributed every byte: a script charges each byte of the minified chunk, and each bit of its gzip stream, to its declaration.
Every size here is gzipped at zlib level 9. "Up front" is the first chunk of a production build of the undra init web template, made by Vite 8.3.1 (its bundler is Rolldown), counting the runtime's own code. Vite's preload helper, a snippet Vite adds to a chunk that loads another on demand, is 691 bytes and is reported beside the number. A share is what code costs in this chunk, not what removing it saves, so every lever below was measured by building. The nine largest of the 24 shares:
| Concern | gzipped bytes |
|---|---|
| wire format: reader, writer, codecs | 3,702 |
| the mirror (each platform's copy of the core's state) | 2,867 |
| the host that runs the core's WebAssembly in the page | 2,521 |
| error classes and their sentences | 2,038 |
| the call path | 1,889 |
| ports: dispatch, default ports, host events | 1,834 |
| core lifecycle: load, options, start, close | 1,805 |
| streams | 945 |
| the bundler's own helper and chunk table | 717 |
Four things stood out. A hello page shipped code it cannot run: streams the template does not have, reconnection for a core that is not remote, 20 of 24 codecs. Rolldown decides where a module goes by re-exports, not by use: a module the entry reaches statically through any file with code of its own lands in the first chunk, even if only an on-demand chunk uses it. Those are Rolldown's rules; another bundler may place modules differently and save less. The sentences of errors were about a tenth of the chunk. And 717 bytes were the bundler's: each on-demand chunk costs the first chunk 15 to 30 bytes of file name, so every lever that makes something lazy grows that share a little.
The levers
Each landed with its measured number. gzip is not additive: a row is what it saved on top of the rows above. The table is as built, before the review's fixes below, whose cost of 131 bytes includes row 8's part.
| # | Lever | after | saved |
|---|---|---|---|
main before | 22,100 | ||
| 1 | session payloads re-exported by the wire format's index file (a barrel) | 21,971 | 129 |
| 2 | nine port ids as literals, pinned by a test | 21,766 | 205 |
| 3, 11a | codecs as a module namespace; unnamed codecs in a module of their own | 20,799 | 967 |
| 4 | streams as a feature the generated entry passes | 20,152 | 647 |
| 5 | a typed channel to the in-process host | 19,458 | 694 |
| 6, 7 | observe waiters, and code only a core outside this thread needs, load with their transport | 18,942 | 516 |
| 8 | stats, snapshot, restore, runInBackground on first call; the review put snapshot and restore back up front | 18,580 | 362 |
| 9, 10a | each on-demand transport maps its own options; the sync-port refusal text | 18,311 | 269 |
| 11b | two rarely thrown error classes | 18,158 | 153 |
| 12 | trims | 18,079 | 79 |
| 13 | the production build says error codes, with a link | 16,763 | 1,316 |
| 14 | the production build renames private properties | 16,193 | 570 |
| 15 | Vite's preload helper reported beside the number | 15,679 | 514 |
| 4b | no bundler warning in an app with a stream | 15,680 | +1 |
Row 15 changes what is counted, not what is shipped, so the other reading has a gate of its own: with Vite's helper left in the chunk, the final record is 16,285 bytes against 16,600. Row 13 is the largest single lever. A production page carries an error's class, kind and fields, and its text is a code (T and a number), the values and a link to the error codes; the development build keeps the sentences.
What left, and what stays
What left loads when it is first needed: stream support, the framed transports' codecs, reconnection, the observe waiters, stats and background runs, the codecs a schema does not name. What stays up front stays for a reason we measured:
- Keyed-patch merging, because read-your-writes drains are synchronous and could not wait for a chunk.
- The mirror's compaction, because its backlog bound must hold offline too.
- Object identity, because every app's first screen waits for it.
- Connectivity and Lifecycle events, about 530 bytes: a hello core listens to neither, but nothing tells the page so. That needs the core to say when it starts listening, which is an ABI decision with its own record. It is the next lever.
What the review caught
The adversarial review reproduced the sizes to the byte and found four High defects at the levers' edges:
- A stripped declaration.
reclaimwas marked internal, so the published types dropped it, and generated bindings that import it failedtscagainst the installed package. Every suite aliased the sources and stayed green. A test now asks the compiler about every name the generator imports. - Lost order.
snapshot()andrestore()ran after anawait import(), which yields even when the module is cached, so a call made afterrestore()reached the core first. They run at the call again. - A fetch at the wrong moment. The background drain at
pagehideneeded a chunk fetched, when a page is being left or is offline and may not be able to fetch one. It calls the core directly now. - Codes where data was expected. The production build had turned a WebSocket close reason, which reaches the server, and port errors, which reach the core, into codes. Text that leaves the runtime as data is a sentence in both builds.
The fixes cost 131 bytes, to 15,811; smaller fixes and a later merge left the record at 15,774.
What it cost
On Node, an awaited call takes 325 to 333 nanoseconds where it took 313 to 316, about 4 to 5% more (four alternating runs on one machine); callSync did not move, at 162 to 168 nanoseconds before and 162 to 167 after. When the levers landed, the development build's first chunk was 21,151 bytes; it has not been re-measured since the review's fixes and is not gated, since a development page is served module by module. The page that uses every feature (in wasm-main mode, its first chunk plus every on-demand runtime chunk except the Worker script) loads 39,922 bytes, less than the 42,385 it loaded before.
The gate has three rows: the hello page (16,000 bytes), the hello page with Vite's helper counted (16,600) and the page that uses every feature (42,400, or 5% over its record, whichever is lower). A change that takes a row over its ceiling, or moves a runtime module into or out of the first chunk, fails the build; over a budget, the message names the levers left, the next being about 530 bytes of host events.