Web Workers
โฑ๏ธ ~3-minute bite ยท solve the sandbox to master
5-Year-Old Metaphor
โ The physical, real-world picture. No jargon.๐ผ Web Worker = hiring an assistant to do the accounting while you keep talking to customers. You can't share your desk (no DOM access), but you can pass notes (postMessage).
The office analogy
You (main thread) handle the front desk โ talking to customers (DOM events), updating the display board (rendering), and running the register (JS execution). You can only do one thing at a time.
Your assistant (Web Worker) has their own desk in the back office. They can crunch numbers, process files, or run queries โ without interrupting you. They leave notes on your desk when done (postMessage).
The problem:your assistant can't come out front and update the display board themselves (no DOM access). Everything they produce must be handed to you via notes, and you update the display board.
Three types of workers
๐ทWeb Worker
Per-page
CPU-intensive work: crypto, image processing, data parsing, prime sieves
๐Shared Worker
Per-origin (all tabs)
Cross-tab state: shared WebSockets, real-time collaboration, cross-tab cache
๐งService Worker
Per-origin (network proxy)
Offline mode, background sync, push notifications, network interception
ASCII: main thread vs worker thread
| 1 | โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ |
| 2 | โ MAIN THREAD โ โ WORKER THREAD โ |
| 3 | โ โ โ โ |
| 4 | โ โ DOM access โ โ โ No DOM access โ |
| 5 | โ โ Window, document โ โ โ No window object โ |
| 6 | โ โ LocalStorage โ โ โ IndexedDB โ |
| 7 | โ โ UI events โ โ โ fetch, XHR โ |
| 8 | โ โ Canvas (main) โ โ โ WebSockets โ |
| 9 | โ โ requestAnimationFrame โ โ โ Math, crypto โ |
| 10 | โ โ โ โ OffscreenCanvas โ |
| 11 | โ Event loop driven โ โ Event loop driven โ |
| 12 | โ (single-threaded) โ โ (separate OS thread) โ |
| 13 | โโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโ |
| 14 | โ postMessage(data) โ |
| 15 | โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโบโ |
| 16 | โ โ |
| 17 | โ postMessage(result) โ |
| 18 | โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ |
| 19 | โ โ |
| 20 | onmessage โ update UI compute / process |
The structured clone algorithm
postMessage serializes data using the structured clone algorithm โ a superset of JSON. Supports: primitives, Arrays, Objects, Date, Map, Set, RegExp, ArrayBuffer, Blob, ImageData. Does NOT support: functions, DOM nodes, or class instances with methods.
Interactive Sandbox
โ Move something, see it react instantly.Worker type
Heavy computation moved off main thread. postMessage/onmessage for communication.
Message timeline
| 1 | // worker.js |
| 2 | self.onmessage = function({ data }) { |
| 3 | if (data.type === 'compute') { |
| 4 | const primes = findPrimes(data.n); |
| 5 | self.postMessage({ primes, count: primes.length }); |
| 6 | } |
| 7 | }; |
| 8 | ย |
| 9 | function findPrimes(n) { |
| 10 | const sieve = new Uint8Array(n + 1); |
| 11 | const primes = []; |
| 12 | for (let i = 2; i <= n; i++) { |
| 13 | if (!sieve[i]) { |
| 14 | primes.push(i); |
| 15 | for (let j = i * i; j <= n; j += i) sieve[j] = 1; |
| 16 | } |
| 17 | } |
| 18 | return primes; |
| 19 | } |
Challenge
View all 5 worker types. For each, check both the worker code and main code tabs โ the communication pattern is different for each type.
Why Should I Care?
โ The exact interview question + the bug it kills.Exact interview questions
Q: Why can't Web Workers access the DOM?
The DOM is not thread-safe. If two threads could modify the DOM simultaneously, you'd need locking โ and lock contention would negate the performance gains. The browser spec made a deliberate decision: workers get a clean execution environment (no DOM, no window) but true parallelism. All DOM updates must be routed through the main thread.
| 1 | // โ Inside worker โ throws ReferenceError |
| 2 | document.getElementById('output').textContent = result; |
| 3 | ย |
| 4 | // โ Post result to main thread, update DOM there |
| 5 | self.postMessage({ result }); |
| 6 | // In main: worker.onmessage = ({data}) => el.textContent = data.result |
Q: What is the difference between Web Worker, Shared Worker, and Service Worker?
Web Worker: dedicated to one page/tab. Terminated when the page closes. Use for heavy computation.
Shared Worker: one instance shared across all tabs on the same origin. Persists as long as any tab has a reference. Use for cross-tab communication without a server.
Service Worker: a network proxy that persists independently of any page. Acts as an offline-capable middleware between your app and the network. Use for PWA features: caching, push notifications, background sync.
| 1 | // Web Worker โ one per tab |
| 2 | const w = new Worker('./heavy-task.js'); |
| 3 | ย |
| 4 | // Shared Worker โ one per origin |
| 5 | const sw = new SharedWorker('./shared-state.js'); |
| 6 | sw.port.start(); |
| 7 | sw.port.postMessage('hello'); |
| 8 | ย |
| 9 | // Service Worker โ registered at origin level |
| 10 | navigator.serviceWorker.register('/sw.js'); |
Q: What are Transferable objects?
Transferable objects are moved between threads rather than copied. The source loses access โ the ArrayBuffer is detached in the sender and available in the receiver. Zero-copy: no memory duplication for large buffers.
Transferable types: ArrayBuffer, MessagePort, OffscreenCanvas, ReadableStream, WritableStream, TransformStream, ImageBitmap, VideoFrame.
| 1 | // Transfer (move, not copy) โ source is detached |
| 2 | const buffer = new ArrayBuffer(1024 * 1024); // 1MB |
| 3 | worker.postMessage({ buffer }, [buffer]); // [buffer] = transfer list |
| 4 | console.log(buffer.byteLength); // 0 โ detached! |
| 5 | ย |
| 6 | // Copy (default) โ both sides have a copy |
| 7 | worker.postMessage({ buffer }); // no transfer list = clone |
| 8 | console.log(buffer.byteLength); // 1048576 โ still valid |
Production patterns
Pattern: Comlink โ make workers feel like async functions
Comlink (by Google Chrome Labs) wraps postMessage/onmessage behind a Proxy, making worker calls look like async function calls. No manual message routing.
| 1 | // worker.js |
| 2 | import { expose } from 'comlink'; |
| 3 | const api = { |
| 4 | async computePrimes(n) { return findPrimes(n); } |
| 5 | }; |
| 6 | expose(api); |
| 7 | ย |
| 8 | // main.js |
| 9 | import { wrap } from 'comlink'; |
| 10 | const worker = new Worker('./worker.js'); |
| 11 | const api = wrap(worker); |
| 12 | const primes = await api.computePrimes(1_000_000); |
| 13 | // No postMessage/onmessage โ just async/await! |
Pattern: Worker pool for parallel processing
For maximum throughput, create a pool of workers equal to navigator.hardwareConcurrency (number of CPU cores). Distribute tasks across the pool and collect results.
| 1 | const POOL_SIZE = navigator.hardwareConcurrency || 4; |
| 2 | const pool = Array.from({ length: POOL_SIZE }, |
| 3 | () => new Worker('./task-worker.js') |
| 4 | ); |
| 5 | let poolIndex = 0; |
| 6 | ย |
| 7 | function runInPool(data) { |
| 8 | return new Promise(resolve => { |
| 9 | const worker = pool[poolIndex++ % POOL_SIZE]; |
| 10 | worker.onmessage = ({ data }) => resolve(data); |
| 11 | worker.postMessage(data); |
| 12 | }); |
| 13 | } |
| 14 | ย |
| 15 | // Process 1000 items across all CPU cores |
| 16 | const results = await Promise.all(items.map(runInPool)); |
The Deep Dive
โ Spec refs, engine internals, the minutiae.Structured clone algorithm โ what it supports
| Type | Supported | Notes |
|---|---|---|
| Primitives (string, number, bool) | โ | Copied by value |
| Date, RegExp | โ | Reconstructed |
| Array, Object, Map, Set | โ | Deep cloned |
| ArrayBuffer, TypedArray | โ | Cloned or transferred |
| Blob, File, ImageData | โ | Cloned |
| Error objects | โ | message + stack preserved |
| Functions | โ | Not serializable |
| DOM nodes | โ | Worker has no DOM |
| Class instances (methods) | โ | Only own enumerable properties |
| Symbol | โ | Not supported |
SharedArrayBuffer security requirements (COOP/COEP)
SharedArrayBuffer was disabled in all browsers in 2018 after Spectre. It was re-enabled in 2020 only for cross-origin isolated pages. Your server must send these headers:
| 1 | # Required response headers |
| 2 | Cross-Origin-Opener-Policy: same-origin |
| 3 | Cross-Origin-Embedder-Policy: require-corp |
| 4 | ย |
| 5 | # Check isolation in JS |
| 6 | console.log(self.crossOriginIsolated); // must be true |
| 7 | ย |
| 8 | # In Next.js (next.config.js) |
| 9 | headers: async () => [{ |
| 10 | source: '/(.*)', |
| 11 | headers: [ |
| 12 | { key: 'Cross-Origin-Opener-Policy', value: 'same-origin' }, |
| 13 | { key: 'Cross-Origin-Embedder-Policy', value: 'require-corp' }, |
| 14 | ], |
| 15 | }] |
Cross-origin isolation prevents embedding third-party content (iframes, images from other origins) unless they opt in with Cross-Origin-Resource-Policy: cross-origin.
Comlink library
Comlink (1.1KB gzipped) wraps the postMessage protocol behind ES6 Proxy, making cross-thread calls look like normal async function calls. Under the hood: every call is assigned a nonce, tracked in a Map, and resolved when the corresponding response arrives.
| 1 | // worker.js |
| 2 | import * as Comlink from 'comlink'; |
| 3 | Comlink.expose({ |
| 4 | processImage: async (buffer) => { |
| 5 | const view = new Uint8Array(buffer); |
| 6 | // ... heavy processing ... |
| 7 | return processedBuffer; |
| 8 | } |
| 9 | }); |
| 10 | ย |
| 11 | // main.js |
| 12 | import * as Comlink from 'comlink'; |
| 13 | const worker = new Worker('./worker.js'); |
| 14 | const api = Comlink.wrap(worker); |
| 15 | ย |
| 16 | // Feels like a local async call โ runs in worker |
| 17 | const result = await api.processImage( |
| 18 | Comlink.transfer(buffer, [buffer]) |
| 19 | ); |
Worker threads in Node.js
Node.js has the worker_threads module (stable since v12). Similar API to browser Workers but with direct SharedArrayBuffer support (no COOP/COEP needed server-side) and workerData for initialization.
| 1 | import { Worker, isMainThread, workerData, parentPort } |
| 2 | from 'worker_threads'; |
| 3 | ย |
| 4 | if (isMainThread) { |
| 5 | const worker = new Worker(__filename, { |
| 6 | workerData: { n: 1_000_000 } |
| 7 | }); |
| 8 | worker.on('message', result => console.log(result)); |
| 9 | } else { |
| 10 | // Running in worker thread |
| 11 | const primes = findPrimes(workerData.n); |
| 12 | parentPort.postMessage(primes.length); |
| 13 | } |
WASM + Workers combo
WebAssembly runs inside Workers โ this is the ultimate performance pattern. Offload both the JS-to-native performance boundary (WASM) AND the main-thread blocking problem (Worker) simultaneously.
| 1 | // wasm-worker.js |
| 2 | import initWasm, { processData } from './pkg/my_wasm.js'; |
| 3 | ย |
| 4 | let wasmReady = initWasm(); // initialize WASM module |
| 5 | ย |
| 6 | self.onmessage = async ({ data }) => { |
| 7 | await wasmReady; // ensure WASM is loaded |
| 8 | const result = processData(data.input); // WASM call in worker |
| 9 | self.postMessage(result); |
| 10 | }; |
| 11 | ย |
| 12 | // main.js |
| 13 | const worker = new Worker('./wasm-worker.js'); |
| 14 | worker.postMessage({ input: largeDataset }); |
| 15 | worker.onmessage = ({ data }) => renderResults(data); |
| 16 | // Main thread: 0ms blocking. WASM in worker: full CPU. |
Interview Questions
โ Real questions from real interviews โ with answers.The DOM is not thread-safe. Concurrent mutation would require locking, negating the parallelism benefit.
Structured clone is a deep-copy algorithm used by postMessage. It handles most JS types but not functions, DOM nodes, or class methods.
Transferables are moved (zero-copy) between threads rather than copied โ the source is detached after transfer.
COOP: same-origin and COEP: require-corp are required. They enforce cross-origin isolation to mitigate Spectre-class timing attacks.
Shared Worker when you need one worker shared across multiple tabs โ e.g., a WebSocket connection, cross-tab state sync, or shared computation cache.
Pool size = navigator.hardwareConcurrency (CPU logical cores). Distribute tasks round-robin or by availability and collect results with Promises.
Memory Game
โ Quick quiz โ lock the concept in long-term memory.What is the BroadcastChannel API and how does it differ from SharedWorker for cross-tab communication?