Under the Hood
Eventloop

Web Workers: real threads across a message boundary

Everything so far in this module has been one thread juggling itself — a Web Worker is the one genuine escape hatch, a second OS thread with its own complete event loop that talks to the main thread only by passing copied messages, never shared memory.

Web Workers: real threads across a message boundary

Every lesson in this module so far has been about one thread being clever with its time — a single call stack, a single set of queues, one thing running at any instant, and an entire toolbox (tasks, microtasks, rendering, promises) for interleaving work on top of that one thread without ever actually running two things at once. A Web Worker is different in kind, not degree: it is a second, genuinely separate OS thread, with its own call stack, its own task and microtask queues, and its own event loop running independently of the main thread's.

The catch — and it's a deliberate one — is that the two threads share no memory by default. They can't reach into each other's variables, can't call each other's functions, can't touch the same object. The only way they talk is by passing messages across a boundary, and those messages are copies. That restriction isn't a limitation bolted on as an afterthought; it's the exact same "no shared mutable state, no data races" principle that makes the single main thread safe, now extended to keep a second thread safe too.

A worker is a whole separate JavaScript world

Spin one up and you get an independent execution environment:

// main.js
const worker = new Worker('worker.js');

That one line starts a new OS thread running worker.js from scratch. It has its own global scope (no window, no document — a worker cannot touch the DOM at all), its own call stack, and its own event loop pulling from its own task and microtask queues. Nothing about it is shared with the thread that created it: a variable declared in main.js does not exist inside the worker, and vice versa. If the worker sets an infinite loop running, the main thread's event loop is completely unaffected — clicks still get handled, frames still render, because it's a different stack being blocked, on a different thread.

Two independent loops, running concurrently on two independent threads, connected by exactly one channel.

The only channel: postMessage, arriving as a task

Neither side can call into the other directly. The entire API is postMessage to send, and a message event to receive:

// main.js
const worker = new Worker('worker.js');
worker.postMessage({ cmd: 'start', n: 40 });
worker.onmessage = (e) => console.log('result from worker:', e.data);

// worker.js
self.onmessage = (e) => {
  const result = fib(e.data.n); // heavy synchronous work, off the main thread
  self.postMessage(result);
};
function fib(n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); }

Calling postMessage doesn't invoke the other side's handler synchronously — it queues a message event as an ordinary task in the receiving thread's task queue, to be picked up by that thread's own event loop the next time its stack is empty. This is exactly the run-to-completion model from lesson 1, just running on two threads instead of one: each side processes its incoming messages as tasks, in its own loop, on its own schedule.

Structured clone: why there are no data races here either

The data you hand to postMessage is not shared — it is deep-copied by an algorithm called structured clone. It walks the value and produces an independent copy on the other side: plain objects, arrays, Maps, Sets, typed arrays, dates, and more all clone correctly, preserving cycles and nested structure. Functions and DOM nodes cannot be cloned at all (and posting one throws), which is a direct consequence of the same rule — a function closes over state on its own thread, and a DOM node belongs to the main thread specifically, so neither has a meaningful copy on the other side.

This is precisely why worker communication can't produce the data races that plague genuinely shared-memory threading: after a postMessage, the two threads hold two independent copies of the data, so nothing either thread does afterward can be observed as a half-finished mutation by the other. It's the same "no shared state" guarantee the single main thread gives you for free, deliberately preserved even once you add a second thread.

Transferables: moving data instead of copying it

For an ArrayBuffer, an OffscreenCanvas, or a MessagePort, you can opt out of copying entirely and transfer ownership instead, by passing a second argument to postMessage:

const buffer = new ArrayBuffer(1024 * 1024 * 64); // 64MB
worker.postMessage({ buffer }, [buffer]);
// After this line, `buffer` on the main thread is neutered — its
// byteLength is now 0. Ownership moved to the worker; nothing was copied.

This is a move, not a copy: the underlying memory isn't duplicated, just handed to the other thread, which is why it's effectively free regardless of size — moving a 64MB buffer costs about the same as moving a 64-byte one. The tradeoff is that the sending side loses access; trying to read buffer after transferring it just sees an empty, detached buffer. For large binary payloads — a video frame, an audio block, pixel data destined for an OffscreenCanvas — transferables are the difference between a message that costs milliseconds and one that costs nothing.

SharedArrayBuffer + Atomics: the one real exception

There is exactly one mechanism that breaks the "no shared memory" rule on purpose: a SharedArrayBuffer, paired with the Atomics API for coordinating reads and writes without tearing. Both threads can hold a reference to the same underlying memory, which means real shared-memory concurrency — and real potential for data races — enters the picture, guarded only by whatever synchronization you build with Atomics.wait/Atomics.notify.

Because this reopens exactly the kind of cross-thread timing attack (Spectre) that structured clone was designed to prevent, browsers only allow SharedArrayBuffer on pages that opt into cross-origin isolation — sending Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp response headers. This is the same COOP/COEP requirement covered in the WASM threads lesson, for the same reason: shared memory across threads is powerful and dangerous in exactly the same ways whether the code driving it is JavaScript or compiled WebAssembly.

The payoff: keep the main thread's loop free

The reason any of this is worth the ceremony is the frame budget from earlier in the curriculum: the main thread has to produce a new frame roughly every 16.6ms, and run-to-completion means any long task on that thread blocks rendering and input handling for its entire duration. Move the heavy computation to a worker instead, and the main thread's stack never holds it at all — the loop stays free to render and respond to input while the worker grinds away on its own thread, delivering a result via postMessage whenever it's done.

This is the same move that shows up, in different clothes, across the rest of this curriculum: running WebAssembly inside a worker to keep heavy compute off the main thread, driving an OffscreenCanvas from a worker so canvas rendering doesn't compete with the DOM for main-thread time, and Web Audio's own dedicated audio-rendering thread — the subject of an AudioWorklet — which exists precisely because audio glitches if it ever has to wait on whatever the main thread happens to be busy with. Every one of these is the same principle: give expensive work its own thread, and let the main thread's event loop do only what it must.

Worker flavors, briefly

A dedicated worker (what this lesson has shown) is owned by a single page. A shared worker can be connected to from multiple tabs or windows of the same origin at once, communicating over a MessagePort each connection gets. A service worker is different again — it doesn't run continuously alongside a page at all, but is installed once and then woken up by the browser to intercept network requests or handle push notifications, forming the basis of offline support and caching strategies. All three are the same underlying primitive — a separate thread with its own event loop, reached only through message passing — aimed at different lifecycles.

Where this goes next

Workers solve the "don't block the main thread" problem by giving heavy work somewhere else entirely to run. The last lesson in this module, Starvation, jank, and scheduling, covers the complementary toolkit for when the work genuinely has to happen on the main thread — how to break it into pieces, yield control back to the loop between them, and use the browser's own scheduling APIs to stay responsive without a second thread at all.

Go deeper

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. What does a Web Worker have that is genuinely its own, and what does it explicitly not have access to?
  2. When one thread calls postMessage, what exactly arrives on the other side, and where does it land in that thread's execution model?
  3. Explain why structured clone — copying rather than sharing the message data — is what prevents data races between the main thread and a worker.
  4. What is a transferable object, why is transferring an ArrayBuffer nearly free regardless of its size, and what happens to the original reference afterward?
  5. What does SharedArrayBuffer + Atomics allow that ordinary postMessage does not, and why does using it require cross-origin isolation headers?
  6. Explain the main payoff of moving heavy computation into a worker in terms of the frame budget and run-to-completion.
  7. Name the three worker types this lesson mentions and the core difference in how each is reached or its lifecycle.