WorkersGENERALBROWSER-SPECIFICDEVICE-SPECIFIC

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.

The question

What is a worker actually allowed to do, and why can it not touch the DOM?

The user intent

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 obvious build

The computation is slow, so move it into a worker. Same code, different file — and when it finishes, the worker updates the table.

Why it breaks

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.

How it breaks in a real browser
  • 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.
  • There is no window either, and library code that feature-detects with typeof 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.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • A worker is a separate JavaScript realm: its own global object (DedicatedWorkerGlobalScope, reachable as self), 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, importScripts or 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 SharedWorker is reachable from several same-origin documents through ports; a ServiceWorker is 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 message event 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.

What lives on which side
reads + mutatespostMessage(data)message taskallowedblockedpostMessage(result)message taskMain thread (page realm)DOM / CSSOM / a11y treepostMessage queueStyle → Layout → PaintWorker thread (own realm)Own heap, own event loopfetch / IndexedDB / WASMdocument, window: not defined
UserLLMAgentToolDataDecisionHumanGuardrail

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.

analyse.worker.ts, and the page that drives it
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.data
10 let sum = 0
11 for (let i = 0; i < rows.length; i++) {
12 sum += rows[i]
13 // progress is a message, never a DOM write
14 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 document
29const worker = new Worker(new URL('./analyse.worker.ts', import.meta.url), { type: 'module' })
30const status = document.getElementById('analysis-status')! // exists up front
31let current = 0
32
33worker.onmessage = (e: MessageEvent<Out>) => {
34 if (e.data.id !== current) return // an answer to an old question
35 if (e.data.type === 'progress') {
36 status.textContent = `Analysed ${e.data.done.toLocaleString()} of ${e.data.total.toLocaleString()} rows`
37 return
38 }
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 += 1
45 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

BROWSER-SPECIFICModule workers ({ 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.

CapabilityIn a worker?Why, and what to do instead
document, window, DOM nodesNoThe document has no locking because one thread owns it. Post data back and mutate from the page.
localStorage / sessionStorageNoSynchronous storage would block a thread the platform wants non-blocking. Use IndexedDB, which is async and available (IndexedDB).
alert, confirm, promptNoThey are document-modal UI. Post a message and let the page decide.
fetch, XMLHttpRequest, WebSocketYesFetching inside the worker avoids ever cloning the payload across the boundary — often the biggest win available.
IndexedDB, Cache, crypto.subtleYesAsync, thread-safe by design, and the reason a worker can own a whole data layer.
Timers, queueMicrotask, promisesYesThe worker has its own full event loop and microtask queue (The Microtask Checkpoint).
WebAssemblyYesThe most common reason to reach for a worker at all: a compute kernel that would otherwise own the main thread.
OffscreenCanvasYesThe one path to producing pixels off-thread. Transfer the context; the compositor does the rest (Compositing Layers).
performance.now, consoleYesTiming and logging work; the console output is attributed to the worker context, not the page.
importScripts / ESM importYesClassic 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 document at import time is how the ReferenceError gets in (The Module Graph).
  • Handle worker.onerror and worker.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-live announcements 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 postMessage back 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 an aria-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-busy or 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

Failure modes
  • Shared code that touches document, window or localStorage at 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 setInterval for 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).
  • importScripts or 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.
What can arrive out of order
  • 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.
Security
  • A worker runs in the same origin as its document and inherits its privileges: same cookies on its fetch calls, 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 to child-src then default-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 innerHTML has simply moved the sink across the wire (Sanitization and Trusted HTML).
Misreads
  • "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, WebAssembly and OffscreenCanvas all 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

How you would see this
  • 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.log inside 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).
Slow device, slow network, large data, old tab
  • 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.
What this costs
  • 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. The Worker constructor and postMessage behave 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.

Securitysandboxing
Domains that do not exist yet
  • 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.