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:

Concerngzipped bytes
wire format: reader, writer, codecs3,702
the mirror (each platform's copy of the core's state)2,867
the host that runs the core's WebAssembly in the page2,521
error classes and their sentences2,038
the call path1,889
ports: dispatch, default ports, host events1,834
core lifecycle: load, options, start, close1,805
streams945
the bundler's own helper and chunk table717

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.

#Leveraftersaved
main before22,100
1session payloads re-exported by the wire format's index file (a barrel)21,971129
2nine port ids as literals, pinned by a test21,766205
3, 11acodecs as a module namespace; unnamed codecs in a module of their own20,799967
4streams as a feature the generated entry passes20,152647
5a typed channel to the in-process host19,458694
6, 7observe waiters, and code only a core outside this thread needs, load with their transport18,942516
8stats, snapshot, restore, runInBackground on first call; the review put snapshot and restore back up front18,580362
9, 10aeach on-demand transport maps its own options; the sync-port refusal text18,311269
11btwo rarely thrown error classes18,158153
12trims18,07979
13the production build says error codes, with a link16,7631,316
14the production build renames private properties16,193570
15Vite's preload helper reported beside the number15,679514
4bno bundler warning in an app with a stream15,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. reclaim was marked internal, so the published types dropped it, and generated bindings that import it failed tsc against 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() and restore() ran after an await import(), which yields even when the module is cached, so a call made after restore() reached the core first. They run at the call again.
  • A fetch at the wrong moment. The background drain at pagehide needed 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.