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:

OpEmitted bySemantics
createcreateElement(NS){ id, type, props, ns? } — props arrive wire-serialized; ns keeps SVG/MathML correctly namespaced (foreignObject/desc/title return to HTML).
textcreateTextNode{ id, text } — also how empty comments anchor, so fragments and conditionals have driver-side nodes.
appendinsertBefore / appendChild{ parent, child, before? } — parent 0 is the root container. Fragments splice their children in.
removeremoveChild{ child } — detaches a node wherever it is mounted.
updatere-rendered propsFull re-serialized prop set — the driver diffs against its last-seen set, so unchanged props cost nothing.
utexttext node writeNew text for a text instance — also how element.textContent lands (replaces children with one text node).
clearroot rebuildClears the island container — the renderer's clearContainer / an imperative instance's rebuild.
attrsetAttribute / classList / dataset{ id, name, value } — value: null removes the attribute.
stylestyle mutationsOnly changed keys cross; --* route to setProperty, a trailing !important encodes priority.
listenaddEventListenerhandler 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.
unlistenremoveEventListenertype + handler identify the binding; capture must match the listen's.
emitemit(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/Element resolve to the active instance, so libraries work without an explicit shim.
  • installDomShim(doc) puts the proxy doc on globalThis.document plus a window facade for libraries that read globals at module scope — install it before a dynamic import('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).

app / rendererproxy enginemain-thread replay

RendererMountProp updateClick → 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:

RendererProxy nodesHandlersOps ΣWire ΣBytes/op
Imperative (no framework)12042015218238 KB47
React100313412177 KB53
Vue60212609120 KB47
Svelte121913646162 KB45
SolidJS100313010136 KB46
Angular100513216146 KB46
  • 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 on OffscreenCanvas, 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.