Web Workers and the DOM Boundary
A second JavaScript realm with its own event loop and its own heap, and no access to the document at all — which is the design, not a missing feature.
The intent, the obvious build, and why it breaks
Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.
What is a worker actually allowed to do, and why can it not touch the DOM?
Someone clicks "Analyse" on a 200,000-row export. They expect the button to respond, a progress indicator to move, and the page to stay scrollable while the numbers are crunched.
The computation is slow, so move it into a worker. Same code, different file — and when it finishes, the worker updates the table.
The worker throws on its first line, because the "same code" called document.querySelector. There is no document in a worker: the identifier is not defined, so the failure is a ReferenceError at the top of a file that worked five minutes ago.
- The worker throws on its first line, because the "same code" called
document.querySelector. There is nodocumentin a worker: the identifier is not defined, so the failure is aReferenceErrorat the top of a file that worked five minutes ago. - There is no
windoweither, and library code that feature-detects withtypeof window !== 'undefined'silently takes its server branch — which is often a stub that returns nothing. - The worker cannot render its own progress. It can compute that it is 40% done, but the progress bar is a DOM node on the other side of a message queue.
- The main thread is still the one that renders. Moving the computation off it makes the page responsive; it does not make the eventual DOM update of 200,000 rows any cheaper (What a Mutation Costs).
- Loading the worker is itself a network request, a parse and a compile of a second script. On a cold cache the "instant" version now starts later than the blocking one did.
What is actually happening
In the browser, not in the framework.
- A worker is a separate JavaScript realm: its own global object (
DedicatedWorkerGlobalScope, reachable asself), its own heap, its own module graph, its own event loop and its own microtask queue (The Event Loop, Precisely). - Nothing is shared by reference. Two realms never see the same object, so a mutation on one side is invisible on the other unless it is sent — which is why the whole module is really about the sending (Talking to a Worker).
- The DOM is deliberately excluded. The document is a large mutable graph — nodes, parent pointers, computed style, layout boxes, an accessibility tree — with no locking anywhere in it, because every read and write of it happens on one thread by construction.
- Making it thread-safe would mean synchronising every node access for every page on the web, including the overwhelming majority that never use a worker. The specification chose the other trade: isolate, then copy messages across (The Actor Model in Concurrency).
- What a worker does get is most of the non-DOM platform:
fetch,XMLHttpRequest, timers,WebSocket,IndexedDB,Cache,crypto,WebAssembly,TextDecoder,performance,importScriptsor ES module imports, and — where supported —OffscreenCanvas, which is the one way pixels are produced off-thread. - A dedicated worker belongs to the document that created it, and dies with it. A
SharedWorkeris reachable from several same-origin documents through ports; aServiceWorkeris a different animal entirely, a network proxy with its own lifecycle (The Service Worker Lifecycle).
What this makes the browser do
And which of it is avoidable.
- Spinning up a realm: allocating a heap, installing the global scope, and creating an event loop. This is not free, and it is why worker creation belongs at a moment when nothing is animating.
- Fetching, parsing and compiling the worker script — a second, separate bundle, subject to the same HTTP caching rules as any other script (Content-Hashed Assets).
- Serialising every message in both directions, on the posting thread, before it is queued (Structured Clone and Transferables).
- Scheduling the receiving
messageevent as an ordinary task on the receiving thread's event loop, where it queues behind whatever is already there (Tasks: The Unit That Cannot Be Interrupted). - What it no longer has to do: run your computation on the thread that owes the user a frame. That is the entire win, and it is a large one (The Frame Budget).
Two realms, one document
The clearest way to hold this is that the worker is not "a background function". It is a second, complete JavaScript environment that happens to be reachable from your page through a pipe. It boots, evaluates a script, and then sits in its own event loop waiting for messages exactly the way the page sits waiting for clicks.
Everything follows from the fact that the document lives on only one side of that pipe. The main thread owns the DOM, computed style, layout, the accessibility tree and every browser API that reads or writes them. The worker owns a heap and a CPU, and the only thing it can do about pixels is ask.
The shape that actually works
The worker takes data and returns data. It never decides what the interface looks like, because it cannot see the interface. Keeping that discipline is what makes the pattern maintainable rather than a source of "which side is this function on?" confusion.
Notice where the accessibility work lives in the example below. The worker knows the numbers; only the page can say them out loud, and it does so through a live region that already exists in the DOM.
1// analyse.worker.ts — a realm with no document in it.2// `self` is the DedicatedWorkerGlobalScope, not `window`.3type In = { id: number; rows: Float64Array }4type Out =5 | { id: number; type: 'progress'; done: number; total: number }6 | { id: number; type: 'done'; mean: number; p95: number }7 8self.onmessage = (e: MessageEvent<In>) => {9 const { id, rows } = e.data10 let sum = 011 for (let i = 0; i < rows.length; i++) {12 sum += rows[i]13 // progress is a message, never a DOM write14 if (i % 20_000 === 0) {15 const msg: Out = { id, type: 'progress', done: i, total: rows.length }16 self.postMessage(msg)17 }18 }19 const sorted = rows.slice().sort()20 const msg: Out = {21 id, type: 'done',22 mean: sum / rows.length,23 p95: sorted[Math.floor(sorted.length * 0.95)],24 }25 self.postMessage(msg)26}27 28// page.ts — the only realm allowed to touch the document29const worker = new Worker(new URL('./analyse.worker.ts', import.meta.url), { type: 'module' })30const status = document.getElementById('analysis-status')! // exists up front31let current = 032 33worker.onmessage = (e: MessageEvent<Out>) => {34 if (e.data.id !== current) return // an answer to an old question35 if (e.data.type === 'progress') {36 status.textContent = `Analysed ${e.data.done.toLocaleString()} of ${e.data.total.toLocaleString()} rows`37 return38 }39 status.textContent = `Done. Mean ${e.data.mean.toFixed(2)}, p95 ${e.data.p95.toFixed(2)}.`40}41worker.onerror = (e) => { status.textContent = 'Analysis failed.'; report(e) }42 43function analyse(rows: Float64Array) {44 current += 145 worker.postMessage({ id: current, rows })46}The id check is not defensive padding — without it, a second click renders the first click's answer whenever the first finishes last. And status is queried once, up front, because a live region must be in the DOM before its text changes to be announced.
What you have and what you lost
{ type: 'module' }) and OffscreenCanvas shipped at different times in different engines; Chromium had both first, Firefox and Safari followed, and older Safari versions require a classic worker with importScripts as a fallback. Feature-detect the constructor option rather than assuming.The absence list is short and specific, and it is worth learning rather than discovering. Almost everything a computation needs is present; almost nothing an interface needs is.
The row that surprises people most is OffscreenCanvas. It is the single exception to "workers cannot produce pixels": transfer a canvas's rendering context to a worker and the worker can draw into it directly, with the compositor putting the result on screen without the main thread being involved at all.
| Capability | In a worker? | Why, and what to do instead |
|---|---|---|
document, window, DOM nodes | No | The document has no locking because one thread owns it. Post data back and mutate from the page. |
localStorage / sessionStorage | No | Synchronous storage would block a thread the platform wants non-blocking. Use IndexedDB, which is async and available (IndexedDB). |
alert, confirm, prompt | No | They are document-modal UI. Post a message and let the page decide. |
fetch, XMLHttpRequest, WebSocket | Yes | Fetching inside the worker avoids ever cloning the payload across the boundary — often the biggest win available. |
IndexedDB, Cache, crypto.subtle | Yes | Async, thread-safe by design, and the reason a worker can own a whole data layer. |
Timers, queueMicrotask, promises | Yes | The worker has its own full event loop and microtask queue (The Microtask Checkpoint). |
WebAssembly | Yes | The most common reason to reach for a worker at all: a compute kernel that would otherwise own the main thread. |
OffscreenCanvas | Yes | The one path to producing pixels off-thread. Transfer the context; the compositor does the rest (Compositing Layers). |
performance.now, console | Yes | Timing and logging work; the console output is attributed to the worker context, not the page. |
importScripts / ESM import | Yes | Classic workers use importScripts; { type: 'module' } workers use real imports (ESM vs CommonJS). |
How to build it
Most important first.
- Draw the boundary around data transformation, not around a feature. The worker should take plain data in and hand plain data back; every DOM decision stays on the main thread where it is legal (Who Owns This State?).
- Have the worker post progress, not paint it. A message every few percent lets the main thread update a progress element and a live region at a rate a person can read (Live Regions and Announcement).
- Build the worker as its own entry point in your bundler and share pure modules with the page. Sharing anything that reaches for
documentat import time is how theReferenceErrorgets in (The Module Graph). - Handle
worker.onerrorandworker.onmessageerror. An uncaught throw inside a worker does not reject anything on the page; without a handler the failure is silent and the UI waits forever. - Terminate workers you no longer need. A dedicated worker holds a realm, its heap and any open connections until
terminate()or document unload (Memory Leaks). - Read Concurrency for the model underneath — what parallelism buys you, what it does not, and why more workers is not linearly faster (Amdahl's Law and More Threads Is Not More Speed there).
Keyboard, focus, semantics, announcement
A required field on every lesson in this domain, not a section added when there is room.
- This is one of the few performance techniques that is directly an accessibility win. The accessibility tree is derived and updated on the main thread; while that thread is blocked, focus moves late,
aria-liveannouncements queue, and a screen-reader user gets silence with no indication that anything is happening (The Accessibility Tree). - A worker cannot announce anything. It has no DOM, therefore no live region, therefore no route to assistive technology. The only path is
postMessageback to the page, and the page updating a live region. - Concretely: the worker posts
{ type: 'progress', done: 40000, total: 200000 }; the main thread writes "Analysed 40,000 of 200,000 rows" into anaria-live="polite"element it already rendered. Creating the live region at the moment of the first message is too late — regions must exist in the DOM before the text changes for the change to be announced. - Throttle those announcements to something a person can follow — a handful over the whole operation, not one per message. A live region updated sixty times a second is a denial of service for a screen reader.
- Keep the trigger control operable and honest: mark it
aria-busyor disable it with an accessible name that says what is happening, and return focus predictably when the work completes (Focus Management).
What can go wrong
- Shared code that touches
document,windoworlocalStorageat module scope. It fails on import, before any of your logic runs. - A third-party dependency that assumes a browser global. The failure surfaces as a stack inside minified vendor code, in a context devtools does not show you by default (A Mental Model of the Devtools).
- A worker created per interaction. Each one pays realm setup and script compile; typing in a search box can create dozens and leave them running.
- A worker that never terminates after the route that owned it unmounted, holding its heap and its
setIntervalfor the life of the tab. - The mitigation failing: the computation moves off-thread and the page is *still* janky, because the real cost was the DOM update, the style recalculation or the layout that follows it (The Cost of a Change).
importScriptsor a worker URL that is cross-origin. A worker script must be same-origin or served with permissive CORS; a CDN without the right headers fails at construction.
- The worker may finish an analysis of input the user has already replaced. Every result must carry the id of the request that produced it, or the UI will render an answer to an old question (Cancelling a Request Nobody Is Waiting For).
- Messages posted before the worker script has finished evaluating are not lost — they queue and are delivered once the worker's event loop starts — but any state the worker sets up asynchronously at startup is not ready, and the first message can observe a half-initialised worker.
- A worker runs in the same origin as its document and inherits its privileges: same cookies on its
fetchcalls, same storage, same IndexedDB. It is not a sandbox and must never be described as one (Origins and the Sandbox). - The worker script itself must be same-origin, a
blob:URL, or served cross-origin with CORS. This is a loading rule, not a security boundary you can lean on. - The document's Content-Security-Policy governs worker creation via
worker-src(falling back tochild-srcthendefault-src), and the worker gets its own policy from the headers its script was served with (Content Security Policy). - Untrusted code in a worker is still untrusted code with your origin's authority. If you need real isolation, the tool is a cross-origin sandboxed iframe, not a worker (Third-Party Scripts and the Supply Chain).
- Because there is no DOM, the classic DOM-XSS sinks are absent inside a worker — but a worker that builds an HTML string and posts it to the page for
innerHTMLhas simply moved the sink across the wire (Sanitization and Trusted HTML).
- "A worker makes my code faster." It makes the main thread free. The computation runs at roughly the same speed, sometimes slower, and the round trip adds latency to the result.
- "Workers are a sandbox." They share your origin, your cookies and your storage. They isolate memory, not authority.
- "I cannot use a worker because I need to update the UI." You update the UI on the main thread from the message the worker sends. The worker never touches it; that is the protocol, not an obstacle.
- "There is no DOM, so nothing platform-y works."
fetch,IndexedDB,WebSocket,crypto,WebAssemblyandOffscreenCanvasall work. The exclusion is the document, not the platform. - "This is the same as the Concurrency lesson." Concurrency teaches what parallel execution means and where it goes wrong. This teaches which browser capabilities exist on which side of a boundary that the browser will not let you cross.
Measuring it, and what changes in the field
- The Performance panel records worker threads as their own tracks alongside the main thread. The shape you are looking for is the main-thread track going quiet while the worker track is busy (A Mental Model of the Devtools).
- The Sources panel lists worker contexts separately; breakpoints and
console.loginside a worker appear under that context, not the page's. - Long-task and interaction measurements are main-thread concepts. If they improve while total CPU time goes *up*, the worker is doing exactly its job (Interaction Responsiveness).
- Watch the network panel for the worker script itself. A worker bundle discovered late is a delay before any of the work can start (Reading a Network Waterfall).
- On a low-core device the worker and the main thread compete for the same CPU. The page stays responsive because the main thread can be scheduled, but the computation itself does not get faster — and on a single-core machine it gets slower (Amdahl's Law).
- On a slow network, the worker script is an extra request on the critical path of the feature that needs it. Preload it if the interaction is likely; do not preload it if it is not (Resource Hints).
- With a very large dataset, the cost of getting the data into the worker can exceed the cost of the computation. That is the point at which transferables or fetching directly inside the worker stops being an optimisation and becomes a requirement (Structured Clone and Transferables).
- In a backgrounded tab, timers throttle and the browser may discard the document entirely. A worker is not a guarantee that background work completes.
- You give up shared memory and direct calls, and get an asynchronous, copy-based protocol in exchange. Every function that crosses the boundary becomes a message with an id, a reply and a failure case (Talking to a Worker).
- Two bundles instead of one: separate entry point, separate compile, duplicated shared dependencies unless your bundler splits them well (Code Splitting).
- Debugging is harder. Stacks stop at the boundary, breakpoints live in a different context, and error reporting needs explicit wiring to reach your error tracker (Frontend Error Tracking).
- Total work goes up — serialise, queue, deserialise — even as the work the user *feels* goes down. Judge it by responsiveness, never by total CPU.
Where this applies
Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.
- GENERALThe realm model — separate global, separate heap, separate event loop, no
document— is specified behaviour and identical across Blink, Gecko and WebKit. TheWorkerconstructor andpostMessagebehave the same everywhere. - BROWSER-SPECIFICTooling and limits are not specified: Chromium shows worker threads as separate Performance-panel tracks and separate Sources contexts, Firefox lists them under about:debugging and names the tracks differently, and each browser caps the number of concurrent workers at an undocumented figure that differs by device class.
- DEVICE-SPECIFICOn a multi-core device the worker runs genuinely in parallel with the main thread; on a single-core or heavily loaded device the two are time-sliced, so responsiveness improves while total wall-clock time gets slightly worse.
Where the depth lives
This domain teaches the browser-side mechanism and hands the rest off.
- — Programming Languages & Runtime Internals — what "a separate realm" costs an engine: a second heap, a second set of intrinsics, and a second compilation of every module you load into it.