The Proxy DOM
How worker-rendered UI stays fast: an op stream, not a VDOM diff
Problem. A worker can't touch
document, and serializing rendered HTML per commit re-sends the whole subtree — losing element identity, focus, scroll position, and event targets.Fix. Give the worker a proxy document and stream diffs: DOM mutations become ops (
createElement,setAttribute, …) replayed incrementally on the main thread, scoped perapp@Nisland instance.
The objection writes itself: a worker can't touch the DOM. True — and
it's exactly why most off-main-thread rendering experiments end at a
<canvas> or a proof-of-concept nobody ships.
Atoll's answer is a proxy document: inside the worker, a JS object
implements just enough document for a real framework renderer to
target — createElement, appendChild, setAttribute, text nodes,
listeners — and every mutation lands on an op queue instead of a DOM.
The pipeline
worker render → proxy DOM mutations → op queue → flush (postMessage)
↓
main thread: driver replays op batches onto real DOM in `el`Events flow the other way — the driver forwards real DOM events into the
island's instance, and emit() sends app-defined messages to the
shell's onEvent. Instance keys (app@N) scope every queue and every
document, so islands sharing a worker are fully isolated.
Why ops, not HTML
The obvious alternative — ship rendered markup per commit — re-sends the whole subtree and destroys node identity: focus lost, scroll state gone, event targets orphaned. Ops are the diffs a framework's commit phase already computes. A two-character text change is a two-field message. That's the granularity worth paying a thread boundary for.
The split that decides whether this is fast:
- Worker side: framework render + op serialization. Million-row table re-renders: ~2–3 ms.
- Main-thread side: op replay + real DOM writes. A virtualized table holds ~22 live DOM rows regardless of logical size — replay cost tracks what's visible, not what's stored.
One rule keeps the stream coherent: worker-initiated DOM work outside a
task re-enters through runInInstance(instance, fn) and
bumpOpsVersion(). Ambient document/window lookup inside a worker
is a heuristic, not a contract — worth knowing before you reach for it.
And the doorbell: with shared memory, an Atomics poke wakes the main
thread the moment ops queue. Drop it — or run somewhere without SAB
headers — and islands flush over ordinary postMessage. Same ops, same
DOM, one fewer capability required. That's the kind of degradation a
production system should have: silent and graceful, not a blank page.
Source: the islands engine guide and the worker-side contract.