@lifeart/async-dom
v2.0.0-alpha.3
Published
Asynchronous DOM rendering — offload UI to Web Workers with frame-budgeted scheduling
Maintainers
Readme
@lifeart/async-dom
Your application runs in a Web Worker. The DOM is just a projection.
async-dom provides a virtual document inside a Web Worker with the full DOM API. Your worker code uses standard DOM operations (createElement, addEventListener, textContent). The main thread receives serialized mutations and applies them at 60 fps. Framework adapters let you embed worker-rendered content inside React, Vue, or Svelte host apps.
This architecture doesn't just improve performance. It fundamentally changes what is accessible to scrapers, bots, browser extensions, and anyone inspecting your page.
Live Demo · Demo with DevTools · npm
Why async-dom?
The web has a content protection problem
Cloudflare blocked 416 billion AI bot requests in the past year. OpenAI's crawl-to-referral ratio is 1,700:1 — they consume vastly more content than they return in traffic. robots.txt is voluntarily ignored. Legal battles (NYT vs OpenAI, Danish publishers vs OpenAI) are slow. The industry needs structural defenses, not polite requests.
The web has a performance problem
JavaScript is single-threaded. The main thread handles rendering, user input, framework execution, and third-party scripts — all competing for the same 16ms frame budget. The result: jank, poor Core Web Vitals, and frustrated users.
The web has a security problem
Traditional web apps expose everything: business logic in bundled JS, data structures in the DOM tree, auth tokens accessible to any XSS payload, and source code available to anyone with DevTools.
async-dom addresses all three.
Real-World Use Cases
Content Protection & Anti-Scraping
| Use Case | How async-dom helps |
| -------- | ------------------- |
| AI scraping prevention | Content never exists in initial HTML. curl and simple scrapers get an empty shell. Headless browsers must wait for worker initialization and mutation application, raising the cost and complexity of automated extraction. |
| Copyright & DRM | Business logic and data stay in the worker. The DOM is a procedural artifact — not a template that maps 1:1 to source content. The architecture enables per-session content variation and server-controlled rendering for content protection scenarios. |
| NDA UI demos | Share interactive prototypes where the client cannot copy JS logic — it runs server-side via WebSocket transport or inside an opaque worker. |
| Exam & education anti-cheat | Application state and logic run in a worker or on a server via WebSocket, making them inaccessible from browser DevTools or in-page scripts. This supplements (but does not replace) purpose-built proctoring solutions. |
| Dynamic obfuscation | The architecture supports per-session variation of non-semantic identifiers (class names, element IDs), increasing maintenance cost for selector-based scrapers. This is an advanced pattern with tradeoffs for CSS tooling and testing. |
Performance & Architecture
| Use Case | How async-dom helps | | -------- | ------------------- | | Main thread liberation | Your entire framework (React, Vue, Svelte) runs off the main thread. Framework runtime does not compete with user input or browser rendering on the main thread. Event round-trips add latency compared to same-thread handlers. | | Heavy computation | Sorting, filtering, data processing, fractal rendering — all happen in the worker without dropping frames. | | Multi-core utilization | Modern devices have 4-8+ cores. Traditional web apps use one. async-dom lets you use the rest. | | SmartTV & low-power devices | Run computation on a backend, stream DOM updates via WebSocket to devices with modern browser support. Frame rate depends on network latency and jitter. | | IoT streaming | Execute the app on a server, stream rendered output to any connected device — TVs, kiosks, embedded displays. |
Multi-Framework & Isolation
| Use Case | How async-dom helps | | -------- | ------------------- | | Framework zoo | Run React, Vue, and Svelte simultaneously on one page — each in its own worker with shadow DOM isolation. Zero conflicts, zero iframes. | | Micro-frontend isolation | Each team ships a worker. CSS is encapsulated via shadow DOM. No shared global state. Independent deployment. | | Version coexistence | Run different versions of the same framework side by side — React 18 and React 19 on one page, no conflicts. | | Cross-platform bridge | Use async-dom as a rendering bridge for React Native, embedded views, or custom renderers. DOM mutations become platform events. |
Collaboration & Debugging
| Use Case | How async-dom helps | | -------- | ------------------- | | Parallel editing | Broadcast a single app instance to multiple viewers via WebSocket. Event forwarding from clients is supported but does not include conflict resolution (events are processed in arrival order). | | Marketing & UX analytics | WebSocket transport broadcasts UI state to multiple observers. Watch exactly what users experience, live. | | Time-travel debugging | Record and replay DOM mutation sequences. Scrub through rendering history with a time-travel scrubber. Compare tree snapshots with visual diff. | | Rendering regression tests | If mutation batches are identical, the UI is identical. Deterministic rendering without pixel comparison. |
Quick Start
npm install @lifeart/async-dommain.ts
import { createAsyncDom } from "@lifeart/async-dom";
const worker = new Worker(new URL("./worker.ts", import.meta.url), {
type: "module",
});
const dom = createAsyncDom({
target: document.getElementById("app")!,
worker,
});
dom.start();worker.ts
import { createWorkerDom } from "@lifeart/async-dom/worker";
const { document } = createWorkerDom();
const div = document.createElement("div");
div.textContent = "Hello from a Web Worker!";
document.body.appendChild(div);
const input = document.createElement("input");
input.addEventListener("input", () => {
console.log("Value:", input.value); // real value from main thread
});
document.body.appendChild(input);That's it. Your app now runs entirely in a worker.
Further Reading
- Getting Started Guide — Mental model, styling, forms, testing, deployment
- Migration Guide — Adopting async-dom in existing apps
- Security Guide — CSP, Trusted Types, COOP/COEP
Framework Adapters
async-dom ships adapters for React, Vue, and Svelte. Your framework code runs in the worker with async-dom's virtual DOM API.
React
import { AsyncDom } from "@lifeart/async-dom/react";
function App() {
return (
<AsyncDom
worker="./app.worker.ts"
debug
fallback={<div>Loading...</div>}
onReady={(instance) => console.log("ready")}
/>
);
}Vue
<template>
<AsyncDom worker="./app.worker.ts" :debug="true" @ready="onReady">
<template #fallback><div>Loading...</div></template>
</AsyncDom>
</template>
<script setup>
import { AsyncDom } from "@lifeart/async-dom/vue";
</script>Svelte
<script>
import { asyncDom } from "@lifeart/async-dom/svelte";
</script>
<div use:asyncDom={{ worker: "./app.worker.ts" }} />Important: Framework adapters are main-thread mount points. They create a container element and spin up a Web Worker. The worker code uses async-dom's virtual DOM API (standard DOM operations), not the framework's component model. See the Getting Started Guide for details.
Remote Transports
async-dom supports running the worker DOM in a SharedWorker, on a remote server via WebSocket, or any custom transport.
Remote App (no local Worker)
import { createAsyncDom } from "@lifeart/async-dom";
import { WebSocketTransport } from "@lifeart/async-dom/transport";
const dom = createAsyncDom({ target: document.getElementById("app")! });
// Connect to a remote server running the app
dom.addRemoteApp({
transport: new WebSocketTransport("ws://localhost:3000"),
name: "remote-app",
mountPoint: "#app",
});
dom.start();SharedWorker Transport
import { createAsyncDom } from "@lifeart/async-dom";
import { SharedWorkerTransport } from "@lifeart/async-dom/transport";
const sw = new SharedWorker("/my-worker.js", { type: "module" });
const transport = new SharedWorkerTransport(sw.port);
const dom = createAsyncDom({ target: document.getElementById("app")! });
dom.addRemoteApp({ transport, name: "shared-worker-app" });
dom.start();Server-Side Rendering (Node.js)
import { createServerApp } from "@lifeart/async-dom/server";
import { WebSocketServerTransport } from "@lifeart/async-dom/server";
// Inside a WebSocket connection handler:
const transport = new WebSocketServerTransport(socket);
const app = createServerApp({
transport,
appModule: ({ document }) => {
const div = document.createElement("div");
div.textContent = "Server-rendered via async-dom";
document.body.appendChild(div);
},
});
// Clean up on disconnect:
socket.on("close", () => app.destroy());Multi-Client Streaming (Optional)
Stream one server-side app instance to multiple browser clients simultaneously. Each client receives full DOM mutation replay on connect and can send events back to the shared app.
Server (streaming-server.ts)
import { createStreamingServer } from "@lifeart/async-dom/server";
import { WebSocketServer } from "ws";
const streaming = createStreamingServer({
createApp: ({ document }) => {
const div = document.createElement("div");
div.textContent = "Hello from server!";
document.body.appendChild(div);
setInterval(() => {
div.textContent = `Server time: ${new Date().toLocaleTimeString()}`;
}, 1000);
},
broadcast: {
mutationLog: { maxEntries: 5000 },
maxClients: 100,
},
});
const wss = new WebSocketServer({ port: 8080 });
wss.on("connection", (ws) => {
const clientId = streaming.handleConnection(ws);
console.log(`Client ${clientId} connected`);
});
await streaming.ready;Client — no special client-side code needed, use the standard transport:
import { createAsyncDom } from "@lifeart/async-dom";
import { WebSocketTransport } from "@lifeart/async-dom/transport";
const asyncDom = createAsyncDom({ target: document.getElementById("app")! });
const transport = new WebSocketTransport("ws://localhost:8080");
asyncDom.addRemoteApp({ transport, name: "shared-app" });
asyncDom.start();StreamingServerInstance API
| Method / Property | Description |
| ----------------- | ----------- |
| handleConnection(socket, clientId?) | Register a new WebSocket client; returns the assigned clientId |
| disconnectClient(clientId) | Remove a specific client |
| getClientCount() | Number of currently connected clients |
| getClientIds() | Array of all active client IDs |
| getDom() | Access the underlying WorkerDom instance |
| destroy() | Shut down the app and disconnect all clients |
| ready | Promise that resolves when the app has finished initializing |
Features
- Late-joining clients automatically receive a replay of all past mutations before switching to the live stream.
- A client disconnect does not affect the server app or other clients.
- Events from each client are tagged with the originating
clientIdbefore reaching the app. - Mutation log size and maximum client count are configurable.
- Backpressure is managed independently per client.
Limitations & Known Gaps
- No conflict resolution — Events from concurrent clients are processed in arrival order (FIFO). No last-writer-wins or ownership model is implemented.
- Replay safety — Late-joining clients receive a full mutation log replay. Non-idempotent mutations (
addEventListener,callMethod,insertAdjacentHTML) may cause duplicate side effects during replay. - No log compaction — The mutation log grows linearly up to
maxEntries. Snapshot-based compaction is not yet implemented. - Single-process — The streaming server runs in a single Node.js process. For high concurrency, external load balancing is needed.
- No built-in authentication —
handleConnectiondoes not validate connections. Authentication must be handled at the WebSocket server level before passing the socket. - No per-client backpressure — A slow client can temporarily degrade broadcast throughput for other clients.
createServerApp remains available for single-client (one app per connection) use cases.
Named Apps (DevTools)
dom.addApp({
name: "dashboard", // visible in DevTools instead of random hash
worker: new Worker("./dashboard.worker.ts", { type: "module" }),
mountPoint: "#dashboard",
shadow: true,
});Package Exports
| Import path | Purpose |
| --------------------- | -------------------------------------------- |
| @lifeart/async-dom | Main thread API (createAsyncDom) |
| @lifeart/async-dom/worker | Worker thread API (virtual document) |
| @lifeart/async-dom/transport | Transport backends (Worker, Binary, WS, SharedWorker, Comlink) |
| @lifeart/async-dom/react | React <AsyncDom> component + useAsyncDom hook |
| @lifeart/async-dom/vue | Vue <AsyncDom> component + useAsyncDom composable |
| @lifeart/async-dom/svelte | Svelte asyncDom action |
| @lifeart/async-dom/vite-plugin | Vite plugin (COOP/COEP headers, binary transport, error overlay) |
| @lifeart/async-dom/server | Server-side runner (createServerApp, createStreamingServer, BroadcastTransport, MutationLog, WebSocketServerTransport) |
For detailed API documentation, see the JSDoc comments on all exported types and functions. Key types: AsyncDomConfig, AsyncDomInstance, WorkerDomConfig, WorkerDomResult.
How It Works
Worker Thread Main Thread
+--------------------+ +---------------------+
| VirtualDocument | | ThreadManager |
| (virtual DOM tree) | | (per-app comms) |
| | | | | |
| MutationCollector | | FrameScheduler |
| (batch + coalesce)| | (budget, sort, |
+--------|----------+ | cull, fairness) |
| | | |
Transport ───────────────> | DomRenderer(s) |
(postMessage / | (per-app, apply |
binary / WS) | to real DOM) |
| | | |
| <─── Events ─────── | EventBridge |
| | (DOM → Worker) |
| | | |
| <─── Sync Reads ──> | SyncChannelHost |
| (SharedArrayBuffer | (Atomics.notify) |
| + Atomics.wait) | |
+--------|----------+ +---------------------+
| SyncChannel |
| (blocking reads) |
+--------------------+- Worker — Your framework runs here. Virtual
documentandwindowprovide the full DOM API. Mutations are batched and coalesced automatically. - Transport — Mutations are serialized (structured clone, binary codec, or WebSocket) and sent to the main thread.
- Scheduler — The main thread applies mutations within a per-frame budget. Priority sorting, viewport culling, and adaptive batch sizing targets 60 fps.
- Events — User interactions on the main thread are serialized and dispatched to worker event handlers.
- Sync Reads —
getBoundingClientRect(),offsetWidth,getComputedStyle()block in the worker viaSharedArrayBuffer+Atomicsand return real values from the main thread.
Security Model
async-dom provides multiple layers of protection:
Worker Isolation (Architectural)
- No direct DOM access — XSS payloads in the page cannot reach worker internal state.
- Serialized communication only — all data passes through
postMessage, a natural sanitization boundary. - Separate execution context — workers are isolated at the browser engine level. Main-thread scripts cannot access worker internal state. Note: browser extensions with appropriate permissions can still read the rendered DOM.
- Token protection — auth tokens and session state in the worker are inaccessible to malicious main-thread scripts.
Content Sanitization (Active)
- HTML sanitizer —
innerHTMLstrips<script>,<iframe>,<style>,<object>,on*attributes, andjavascript:/data:text/htmlURIs. - Property allowlist —
setPropertyonly applies safe properties (value,checked,textContent, etc.). - Attribute filtering —
setAttributeblockson*handlers and dangerous URIs.
Anti-Scraping (Structural)
Unlike robots.txt (voluntary), CDN-level blocks (circumventable), or CAPTCHAs (UX-degrading), worker-based rendering is an architectural property that raises the cost of content extraction:
- Empty HTML payload — no content for
curl,wget, or simple GET requests. - Procedural DOM — the rendered tree is an artifact of the mutation protocol, not a semantic template.
- Dynamic structure — the architecture supports per-session variation of class names and DOM structure, raising the maintenance burden for selector-based scrapers.
- Honeypot injection — the worker can be programmed to insert invisible trap elements that automated tools follow but humans never see.
- Behavioral gating — the worker controls what renders and when, enabling application-level bot detection logic.
Transports
| Transport | Use case |
| --------- | -------- |
| WorkerTransport | Default — structured clone via postMessage |
| BinaryWorkerTransport | Production — 22-opcode binary codec with string deduplication |
| WebSocketTransport | Remote rendering — WebSocket with auto-reconnect and exponential backoff |
| createComlinkEndpoint | RPC — Comlink adapter (optional peer dependency) |
WebSocket transport enables powerful patterns: server-side rendering to any device, collaborative multi-user editing, and IoT streaming.
Per-App Isolation
Run multiple independent applications on one page. Each gets its own renderer, node cache, event bridge, and optional shadow DOM:
const dom = createAsyncDom({ target: document.body });
dom.addApp({
worker: new Worker("./react-app.ts", { type: "module" }),
mountPoint: "#panel-a",
shadow: true,
});
dom.addApp({
worker: new Worker("./vue-app.ts", { type: "module" }),
mountPoint: "#panel-b",
shadow: { mode: "closed" },
});
dom.start();Sandbox Mode
Run third-party scripts that expect bare document/window globals — no modifications needed:
// Patch worker globals — bare `document` resolves to virtual DOM
const { document } = createWorkerDom({ sandbox: "global" });
// Sandboxed eval — Proxy + with for full variable interception
const { window } = createWorkerDom({ sandbox: "eval" });
window.eval(`document.body.innerHTML = "<h1>Works!</h1>"`);| Mode | Bare document | eval() sandbox | Use case |
| ---- | ---------------- | ---------------- | -------- |
| "global" | Yes | No | Framework code with bare globals |
| "eval" | No | Yes | Third-party analytics/ads scripts |
| true | Yes | Yes | Maximum compatibility |
Synchronous DOM Reads
Via SharedArrayBuffer + Atomics.wait/notify — real values, not guesses:
| API | Returns |
| --- | ------- |
| el.getBoundingClientRect() | Real DOMRect |
| el.offsetWidth, clientHeight, etc. | Real layout metrics |
| window.getComputedStyle(el) | Real computed styles |
| window.innerWidth / innerHeight | Real viewport size |
Requires COOP/COEP headers (automatic with the Vite plugin):
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corpBuilt-in DevTools
Add ?debug to the URL or set debug: { exposeDevtools: true }:
| Tab | What it shows | | --- | ------------- | | Tree | Virtual DOM tree with node inspector — attributes, styles, event listeners, mutation history, "why updated?" trail. Snapshot & diff. | | Performance | Frame budget flamechart, worker-to-main latency (P50/P95/P99), dropped frames, mutation type chart, coalescing breakdown, sync read heatmap, worker CPU profiler. | | Log | Live mutation stream, color-coded diffs, event round-trip tracer, time-travel replay with scrubber. | | Warnings | Grouped by code with docs and fixes. Suppressible. | | Graph | Causality DAG: events → mutation batches → affected DOM nodes. |
Console API available via __ASYNC_DOM_DEVTOOLS__ for programmatic inspection.
Examples
| Example | Description | Tags | | ------- | ----------- | ---- | | 7000 Nodes Grid | Interactive color grid with 7,000 DOM nodes from a worker | performance, events | | Counter | Minimal example — click handlers, textContent updates | beginner | | Todo List | Input sync, dynamic DOM, classList, keyboard events | input sync, dynamic DOM | | Multi-App | Two workers in shadow DOM — CSS isolation | isolation, shadow DOM | | Audio Player | Audio playback controlled from a worker | media API, callMethod | | React: Mandelbrot | Fractal renderer — 4,800 pixels computed in a worker | React, heavy compute | | Vue: Game of Life | 60x40 grid simulation — 2,400 cell DOM updates | Vue, simulation | | Svelte: Particle Life | 320 particles with attraction/repulsion rules | Svelte, simulation | | Framework Showcase | React + Vue + Svelte on one page, zero framework runtime on main thread | multi-framework | | DevTools Panel | 7000-node grid with built-in debug panel | devtools |
npm run dev # run all examples locallyComparison
| Feature | async-dom | Partytown | @ampproject/worker-dom | | ------- | --------- | ------------------------------------------- | ------------------------------------------------------------ | | Scope | Full app rendering | Third-party scripts only | AMP components only | | Frameworks | React, Vue, Svelte, vanilla | N/A | AMP only | | DOM API coverage | Broad (see compatibility table) | Proxy forwarding | Subset | | Sync reads | SharedArrayBuffer | Service Worker + Atomics | No | | Frame budgeting | Adaptive with priority | No | No | | Binary protocol | 22 opcodes + string dedup | No | Transfer list | | Multi-app isolation | Shadow DOM | No | No | | WebSocket transport | Yes (remote rendering) | No | No | | Content protection | Structural (worker isolation) | No | No | | DevTools | Built-in 5-tab panel | No | No | | Bundle (gzip) | ~21 KB (core, gzip) | ~12 KB | ~12 KB | | Status | Active | Maintenance | Inactive |
DOM API Compatibility
Layout reads require a SharedArrayBuffer sync channel. Without it, they return zero values. All other APIs work without special setup.
| Category | APIs | Status | | -------- | ---- | ------ | | Tree manipulation | appendChild, removeChild, insertBefore, append, prepend, replaceWith, before, after, replaceChildren | Full | | Attributes | get/set/has/removeAttribute, NS variants, attributes iterable | Full | | Properties | id, className, textContent, innerHTML, value, checked, disabled, selectedIndex, type | Full | | ClassList | add, remove, toggle, contains, replace, length | Full | | Style | style proxy (camelCase + kebab-case), cssText | Full | | Dataset | Proxy-based data-* attribute access | Full | | Events | addEventListener, removeEventListener, dispatchEvent, on* handlers, once option | Full | | Queries | querySelector/All, getElementById, getElementsByTagName/ClassName, matches, closest, contains | Full | | Layout reads | clientWidth/Height, scrollWidth/Height, offsetWidth/Height/Top/Left, getBoundingClientRect | Sync | | Scroll | scrollTop, scrollLeft (get/set), scrollIntoView | Full | | Media | play, pause, load, currentTime, duration, paused, ended, readyState | Full | | Methods | focus, blur, click, select, showModal, close | Full | | Clone | cloneNode (shallow + deep) | Full | | Document | createElement, createTextNode, createComment, createDocumentFragment, createEvent, createRange, createTreeWalker | Full | | Navigation | parentNode/Element, first/lastChild, next/previousSibling, first/lastElementChild, children, childElementCount, ownerDocument, isConnected, getRootNode | Full | | insertAdjacentHTML | insertAdjacentHTML | Full | | normalize | normalize() | Stub | | Shadow DOM | attachShadow, shadowRoot | -- | | outerHTML | outerHTML getter (read-only) | Full | | Animations | animate, getAnimations | -- | | Fullscreen | requestFullscreen | -- | | Pointer capture | setPointerCapture, releasePointerCapture | -- |
CLI Scaffold
npx @lifeart/async-dom init my-app --template react-tsTemplates: vanilla-ts, react-ts, vue-ts
Browser Support
| Browser | Minimum | Notes | | ------- | ------- | ----- | | Chrome | 80+ | Full support | | Firefox | 79+ | Full support | | Safari | 15.2+ | Requires COOP/COEP for sync reads | | Edge | 80+ | Full support (Chromium) |
When Not to Use async-dom
- SEO-dependent pages — Worker-rendered content is not visible to search engine crawlers
- Simple apps — The worker overhead (initialization, message serialization, event round-trips) may exceed the benefit for lightweight UIs
- Apps requiring sub-millisecond input response — Event round-trips add 2-20ms of latency compared to same-thread handlers
- Heavy third-party library integration — Libraries that assume direct DOM access (D3, jQuery, analytics SDKs) will not work in the worker
Development
npm install # install dependencies
npm run dev # dev server with examples
npm run build # build ESM + CJS + declarations
npm test # 1,483 tests across 80 files
npm run typecheck # type-check
npm run lint # lint (Biome)Contributing
Contributions welcome. Please open an issue first. See the issue tracker.
License
MIT — see LICENSE.
