Islands — the proxy document
Inside the worker there is no DOM — there is a ProxyDocument: a faithful facade that records every mutation as an op in a queue. Task calls return the flushed batch; the main thread's only job is replaying ops against real nodes. No shared memory is involved — ops ride postMessage.
The pipeline
worker app → ProxyDocument → per-instance op queue → task batch → postMessage → driver replay → real DOM
Every island owns an independent op stream keyed by its app@N instance — separate queue, document, and node map — so several islands can share one worker without cross-talk. The task surface each worker exposes is fixed: mount, updateProps, dispatch, setSize, flush, unmount — each returns the op batch it produced.
The op vocabulary
Twelve ops cover the entire protocol — everything a reconciler or hand-rolled DOM code can express:
| Op | Emitted by | Semantics |
|---|---|---|
create | createElement(NS) | { id, type, props, ns? } — props arrive wire-serialized; ns keeps SVG/MathML correctly namespaced (foreignObject/desc/title return to HTML). |
text | createTextNode | { id, text } — also how empty comments anchor, so fragments and conditionals have driver-side nodes. |
append | insertBefore / appendChild | { parent, child, before? } — parent 0 is the root container. Fragments splice their children in. |
remove | removeChild | { child } — detaches a node wherever it is mounted. |
update | re-rendered props | Full re-serialized prop set — the driver diffs against its last-seen set, so unchanged props cost nothing. |
utext | text node write | New text for a text instance — also how element.textContent lands (replaces children with one text node). |
clear | root rebuild | Clears the island container — the renderer's clearContainer / an imperative instance's rebuild. |
attr | setAttribute / classList / dataset | { id, name, value } — value: null removes the attribute. |
style | style mutations | Only changed keys cross; --* route to setProperty, a trailing !important encodes priority. |
listen | addEventListener | handler is a worker handler-table id — the driver wires the DOM listener to dispatch back. id: 0 targets the root container, which is how delegated/document listeners work. |
unlisten | removeEventListener | type + handler identify the binding; capture must match the listen's. |
emit | emit(name, payload) | The one op that is NOT a DOM mutation — the island→shell event channel, delivered to onEvent. |
DOM coverage
The facade implements what frameworks and libraries actually touch: createElement(NS)/createTextNode/createComment, insertBefore/appendChild/removeChild (fragments splice in), setAttribute(NS), reflected properties (value, checked, src…), classList/style/dataset, textContent, innerHTML, cloneNode, template.content, shadow-tree reads (children, querySelector, getElementsBy*), and addEventListener with { once, passive, capture } crossing the wire.
- Instance-aware globals —
document/window/Elementresolve to the active instance, so libraries work without an explicit shim. installDomShim(doc)puts the proxy doc onglobalThis.documentplus awindowfacade for libraries that read globals at module scope — install it before a dynamicimport('lib').- Worker-initiated work re-enters explicitly — timers and promise continuations have no active instance:
runInInstance(instance, fn)+bumpOpsVersion()to flush.
Geometry — the one honest box
The only measurement channel is the island container itself: the driver observes el with a ResizeObserver and calls setSize(instance, w, h) at mount and on resizes (throttled ~100ms). Geometry reads on doc.body, documentElement, and elements marked doc.markContainer(el) return that box; everything else returns an honest 0. doc.onResize(cb) re-fires on each push — the callbacks' ops ride back in setSize's return batch.
Events
Handlers receive a spec-shaped EventPayload — pointer coords, modifiers, wheel deltas (+deltaMode), key, which, pointerType, target form state, and scrollTop. A synthesized target gives delegated handlers a proxy-DOM node toclosest()/dataset against. preventDefault/stopPropagation exist as no-ops so library code doesn't crash — they cannot cancel anything: the real event already dispatched on the main thread.
Benchmarks — where the time goes
Measured, not estimated: the same 200-row tree mounted through every adapter, then a prop-driven update and a click→state-commit. Each cell splits the JS cost three ways — app (framework render/diff + adapter glue), engine (proxy-DOM bookkeeping: op recording, prop serialization, instance allocation, queue drain), and replay (main thread applying ops to real DOM).
| Renderer | Mount | Prop update | Click → commit |
|---|---|---|---|
| Imperative (no framework) | 0.9+1.7+5.4ms · 2608 ops | 8.1+1+9.6ms · 2609 ops | 0.9+0.1+0.1ms · 1 ops |
| React | 6.8+1.3+8.8ms · 2007 ops | 8.4+0.3+0.8ms · 802 ops | 5.7+0.3+0.2ms · 603 ops |
| Vue | 1.8+0.7+4.3ms · 2408 ops | 5.1+0.2+0.8ms · 200 ops | 1.1+0+0ms · 1 ops |
| Svelte | 5.6+3.3+4.8ms · 3445 ops | 5+0.1+0.2ms · 200 ops | 0.4+0+0ms · 1 ops |
| SolidJS | 0.9+1+7.3ms · 2809 ops | 2.6+0.1+0.1ms · 200 ops | 0.3+0+0ms · 1 ops |
| Angular | 3.9+1.4+9.9ms · 3015 ops | 4.4+0.1+0.1ms · 200 ops | 0.8+0+0ms · 1 ops |
- The engine is cheap relative to the renderer — framework mounts spend ~10–35% of worker time in proxy bookkeeping; the rest is the framework's own render/diff.
- Imperative is the ceiling case — with no framework, the app IS proxy calls, so the engine dominates its worker time; that row is the proxy document's own cost profile.
- Replay scales with op count, not tree size — a diffed framework update emits ~200 ops; the imperative rebuild emits ~2,600 and pays for it on the main thread.
- Clicks are ~all worker — dispatch runs the handler in the island; typically one op crosses back.
Memory & wire inflation
The proxy layer keeps its own state per island — this is what it retains after the three scenarios, plus how much op traffic each renderer generated:
| Renderer | Proxy nodes | Handlers | Ops Σ | Wire Σ | Bytes/op |
|---|---|---|---|---|---|
| Imperative (no framework) | 1204 | 201 | 5218 | 238 KB | 47 |
| React | 1003 | 1 | 3412 | 177 KB | 53 |
| Vue | 602 | 1 | 2609 | 120 KB | 47 |
| Svelte | 1219 | 1 | 3646 | 162 KB | 45 |
| SolidJS | 1003 | 1 | 3010 | 136 KB | 46 |
| Angular | 1005 | 1 | 3216 | 146 KB | 46 |
- One instance record per rendered node — the shadow map keeps id → record for everything the island has mounted, so a 200-row tree costs ~1,000+ proxy records on top of the real DOM nodes.
- Records survive removal — the instance map is never pruned (event dispatch may still resolve a detached target id), so long-lived churning islands accumulate; a full remount is the reclaim.
- ~45–50 bytes per op on the wire — a 200-row mount is a ~100–160 KB structuredClone batch. Big state should live in shared memory, not in props — the serialization boundary is postMessage.
- One happy-dom run on one machine — treat the ratios, not the decimals, as the data. In-process transport is ~free; real workers add marshalling to the worker side. Regenerate with
npm run stats:islands.
Limits
- Only the island container is measured — arbitrary-element geometry and SVG metrics (
getBBox…) return 0. getContext('2d'/'webgl')is out of scope — canvas libraries belong onOffscreenCanvas, a different transport.- Async callbacks that mutate or emit outside a task need
runInInstance.
Byte-side numbers — what each thread loads — are on Bundle size & load; mounting mechanics on Quickstart.