Web Workers
Web Workers enable JavaScript to run in background threads, separate from the main UI thread. This allows computationally expensive operations to execute without blocking the user interface. Workers communicate with the main thread through an asynchronous message-passing system.
The Worker API supports three types: DedicatedWorker (single owner), SharedWorker (shared across multiple contexts), and ServiceWorker (network proxy for offline support). This guide focuses on dedicated and shared workers.
Workers run in a separate global context with no access to the DOM, window, document, or parent. They have access to self, postMessage, onmessage, importScripts, fetch, setTimeout, XMLHttpRequest, and the Cache API.
A dedicated worker is tied to its creator (a script, another worker, or a document). Communication is one-to-one via postMessage and the message event.
| Method / Event | Description |
|---|---|
| new Worker(url) | Creates a new dedicated worker from a script URL |
| worker.postMessage(data) | Sends data to the worker (structured clone) |
| worker.onmessage | Receives messages from the worker |
| worker.onerror | Handles uncaught errors in the worker |
| worker.terminate() | Immediately stops the worker (no cleanup) |
| self.postMessage(data) | Worker sends data back to the main thread |
| self.onmessage | Worker receives messages from the main thread |
| self.close() | Worker terminates itself |
| 1 | // === main.js (main thread) === |
| 2 | |
| 3 | const worker = new Worker("worker.js"); |
| 4 | |
| 5 | worker.postMessage({ type: "compute", payload: { n: 42 } }); |
| 6 | |
| 7 | worker.onmessage = (e) => { |
| 8 | console.log("Received from worker:", e.data); |
| 9 | document.getElementById("result").textContent = e.data.result; |
| 10 | }; |
| 11 | |
| 12 | worker.onerror = (e) => { |
| 13 | console.error("Worker error:", e.message, e.filename, e.lineno); |
| 14 | e.preventDefault(); // prevent default error UI |
| 15 | }; |
| 16 | |
| 17 | // Terminate the worker when done |
| 18 | function stopWorker() { |
| 19 | worker.terminate(); |
| 20 | console.log("Worker terminated"); |
| 21 | } |
| 22 | |
| 23 | // === worker.js (worker thread) === |
| 24 | |
| 25 | self.onmessage = (e) => { |
| 26 | const { type, payload } = e.data; |
| 27 | |
| 28 | if (type === "compute") { |
| 29 | const result = fibonacci(payload.n); |
| 30 | self.postMessage({ type: "result", result }); |
| 31 | } |
| 32 | }; |
| 33 | |
| 34 | function fibonacci(n) { |
| 35 | if (n <= 1) return n; |
| 36 | return fibonacci(n - 1) + fibonacci(n - 2); |
| 37 | } |
| 38 | |
| 39 | // Or terminate from inside |
| 40 | // self.close(); |
info
Workers run in a dedicated global scope. The self keyword refers to the worker's global context. Workers have no access to the DOM but can use many web APIs.
| Available in Worker | Not Available |
|---|---|
| self, onmessage, postMessage | window, document, parent |
| importScripts() | DOM manipulation APIs |
| fetch(), XMLHttpRequest | localStorage, sessionStorage |
| setTimeout, setInterval | alert, confirm, prompt |
| WebSocket, IndexedDB | canvas (direct access) |
| OffscreenCanvas, WebAssembly | Worker nested within worker (DedicatedWorker only) |
| Crypto, Performance, Console | FileReader, Blob (available) |
| 1 | // === worker.js — full scope capabilities === |
| 2 | |
| 3 | // Available APIs |
| 4 | self.console.log("Worker initialized"); |
| 5 | |
| 6 | // HTTP requests |
| 7 | self.fetch("/api/data") |
| 8 | .then((r) => r.json()) |
| 9 | .then((data) => self.postMessage(data)); |
| 10 | |
| 11 | // Timers |
| 12 | const intervalId = setInterval(() => { |
| 13 | self.postMessage({ type: "heartbeat" }); |
| 14 | }, 1000); |
| 15 | |
| 16 | // IndexedDB access |
| 17 | const request = indexedDB.open("MyDatabase", 1); |
| 18 | |
| 19 | // importScripts — load external scripts synchronously |
| 20 | self.importScripts("library.js", "utils.js"); |
| 21 | // All functions from library.js and utils.js are now available |
| 22 | |
| 23 | // WebAssembly |
| 24 | async function loadWasm() { |
| 25 | const response = await fetch("module.wasm"); |
| 26 | const bytes = await response.arrayBuffer(); |
| 27 | const wasm = await WebAssembly.instantiate(bytes, {}); |
| 28 | self.postMessage({ result: wasm.instance.exports.myFunction(42) }); |
| 29 | } |
| 30 | |
| 31 | // OffscreenCanvas for rendering in worker |
| 32 | self.onmessage = (e) => { |
| 33 | if (e.data.canvas) { |
| 34 | const ctx = e.data.canvas.getContext("2d"); |
| 35 | ctx.fillStyle = "#00FF41"; |
| 36 | ctx.fillRect(0, 0, 100, 100); |
| 37 | } |
| 38 | }; |
| 39 | |
| 40 | // Close worker from inside |
| 41 | function shutdown() { |
| 42 | clearInterval(intervalId); |
| 43 | self.close(); |
| 44 | } |
Transferable objects can be moved between threads with zero-copy performance. Instead of cloning the data (which duplicates memory), ownership is transferred. After transfer, the original reference becomes neutered (unusable).
| Transferable Type | Description |
|---|---|
| ArrayBuffer | Raw binary data buffer (most common) |
| MessagePort | Channel messaging port |
| OffscreenCanvas | Canvas rendering context for worker |
| ReadableStream | Stream data without copying |
| WritableStream | Writable stream endpoint |
| WebAssembly.Module | Compiled WebAssembly module |
| 1 | // === Without transfer: copy (slow for large data) === |
| 2 | |
| 3 | const largeBuffer = new ArrayBuffer(1024 * 1024 * 100); // 100MB |
| 4 | worker.postMessage({ data: largeBuffer }); |
| 5 | // This copies 100MB — expensive and blocks the main thread |
| 6 | |
| 7 | // === With transfer: zero-copy (fast) === |
| 8 | |
| 9 | const largeBuffer2 = new ArrayBuffer(1024 * 1024 * 100); |
| 10 | worker.postMessage({ data: largeBuffer2 }, [largeBuffer2]); |
| 11 | // Ownership transferred — no copy, instant return |
| 12 | // After this, largeBuffer2.byteLength === 0 (neutered) |
| 13 | |
| 14 | // Multiple transferables |
| 15 | const buf1 = new ArrayBuffer(1024 * 1024); |
| 16 | const buf2 = new ArrayBuffer(1024 * 1024); |
| 17 | worker.postMessage({ a: buf1, b: buf2 }, [buf1, buf2]); |
| 18 | |
| 19 | // === Image processing with transfer === |
| 20 | |
| 21 | async function processImageInWorker(imageData) { |
| 22 | // Convert ImageData to ArrayBuffer |
| 23 | const buffer = imageData.data.buffer; |
| 24 | |
| 25 | const worker = new Worker("image-worker.js"); |
| 26 | worker.postMessage({ imageBuffer: buffer }, [buffer]); |
| 27 | // Original buffer is now empty — cannot read from it |
| 28 | |
| 29 | return new Promise((resolve) => { |
| 30 | worker.onmessage = (e) => { |
| 31 | // Received processed buffer (ownership transferred back) |
| 32 | const processed = new Uint8ClampedArray(e.data.result); |
| 33 | const newImageData = new ImageData( |
| 34 | processed, |
| 35 | imageData.width, |
| 36 | imageData.height |
| 37 | ); |
| 38 | resolve(newImageData); |
| 39 | }; |
| 40 | }); |
| 41 | } |
| 42 | |
| 43 | // === image-worker.js === |
| 44 | self.onmessage = (e) => { |
| 45 | const buffer = e.data.imageBuffer; |
| 46 | const pixels = new Uint8ClampedArray(buffer); |
| 47 | |
| 48 | // Process pixels (e.g., grayscale) |
| 49 | for (let i = 0; i < pixels.length; i += 4) { |
| 50 | const gray = 0.299 * pixels[i] + 0.587 * pixels[i+1] + 0.114 * pixels[i+2]; |
| 51 | pixels[i] = gray; |
| 52 | pixels[i+1] = gray; |
| 53 | pixels[i+2] = gray; |
| 54 | } |
| 55 | |
| 56 | // Transfer buffer back |
| 57 | self.postMessage({ result: buffer }, [buffer]); |
| 58 | }; |
warning
Web Workers excel at CPU-intensive operations that would otherwise block the UI thread. These are the most common and effective use cases:
| Use Case | Worker Type | Why Worker? |
|---|---|---|
| Heavy Computation | Dedicated | Fibonacci, prime factors, crypto, data analysis |
| Image Processing | Dedicated + Transferable | Filters, compression, OCR, color correction |
| Real-time Data | Dedicated | WebSocket parsing, market data, sensor data |
| File Parsing | Dedicated | CSV/JSON parsing, XML processing, large files |
| Audio / Video | Dedicated | Audio analysis, video frame processing |
| Cross-tab Sync | Shared | Real-time collaboration, shared state |
| Data Compression | Dedicated | Gzip, Brotli compression in the background |
| 1 | // === main.js — heavy computation offloaded === |
| 2 | |
| 3 | function computePrimesInWorker(limit) { |
| 4 | return new Promise((resolve) => { |
| 5 | const worker = new Worker("prime-worker.js"); |
| 6 | worker.postMessage({ limit }); |
| 7 | worker.onmessage = (e) => { |
| 8 | resolve(e.data.primes); |
| 9 | worker.terminate(); |
| 10 | }; |
| 11 | }); |
| 12 | } |
| 13 | |
| 14 | // UI stays responsive during computation |
| 15 | document.getElementById("compute-btn").addEventListener("click", async () => { |
| 16 | showLoadingSpinner(); |
| 17 | const primes = await computePrimesInWorker(1000000); |
| 18 | hideLoadingSpinner(); |
| 19 | displayResults(primes); |
| 20 | }); |
| 21 | |
| 22 | // === prime-worker.js === |
| 23 | |
| 24 | self.onmessage = (e) => { |
| 25 | const { limit } = e.data; |
| 26 | const primes = sieveOfEratosthenes(limit); |
| 27 | self.postMessage({ primes }); |
| 28 | }; |
| 29 | |
| 30 | function sieveOfEratosthenes(limit) { |
| 31 | const sieve = new Uint8Array(limit + 1).fill(1); |
| 32 | sieve[0] = sieve[1] = 0; |
| 33 | |
| 34 | for (let i = 2; i * i <= limit; i++) { |
| 35 | if (sieve[i]) { |
| 36 | for (let j = i * i; j <= limit; j += i) { |
| 37 | sieve[j] = 0; |
| 38 | } |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | const primes = []; |
| 43 | for (let i = 2; i <= limit; i++) { |
| 44 | if (sieve[i]) primes.push(i); |
| 45 | } |
| 46 | return primes; |
| 47 | } |
Live preview — simulated worker computation (workers require separate files, so this shows the worker pattern conceptually):
Workers have important constraints that affect architecture decisions:
| Limitation | Impact | Workaround |
|---|---|---|
| No DOM access | Cannot manipulate HTML | Send results to main thread for rendering |
| No window/document | Cannot access globals like location, navigator | Pass needed values via postMessage |
| No localStorage/sessionStorage | Cannot persist data directly | Use IndexedDB or postMessage to main thread |
| Same-origin policy | Worker script must match page origin | Use CORS headers or blob URLs for inline workers |
| Serialization overhead | postMessage uses structured clone | Use transferable objects for large data |
| Startup cost | Worker creation has overhead | Reuse workers with a pool pattern |
| Limited APIs | No canvas (direct), no WebAudio, no FileReader | Use OffscreenCanvas, transfer buffers |
| Debugging complexity | Separate console, breakpoints need DevTools | Chrome DevTools has dedicated Worker panel |
best practice
Debugging workers requires browser DevTools support. Chrome and Firefox provide dedicated worker inspection panels.
| 1 | // === Debugging techniques === |
| 2 | |
| 3 | // 1. Console logging — appears in worker's own console context |
| 4 | self.console.log("Worker state:", { status: "ready", config }); |
| 5 | |
| 6 | // 2. Error handling — catch and report errors |
| 7 | self.onerror = (e) => { |
| 8 | self.postMessage({ |
| 9 | type: "error", |
| 10 | message: e.message, |
| 11 | filename: e.filename, |
| 12 | lineno: e.lineno |
| 13 | }); |
| 14 | }; |
| 15 | |
| 16 | // 3. Unhandled promise rejection |
| 17 | self.addEventListener("unhandledrejection", (e) => { |
| 18 | self.postMessage({ |
| 19 | type: "error", |
| 20 | message: "Unhandled rejection: " + e.reason |
| 21 | }); |
| 22 | }); |
| 23 | |
| 24 | // 4. Self-diagnostic — report worker capabilities |
| 25 | self.postMessage({ |
| 26 | type: "diagnostics", |
| 27 | hasFetch: typeof self.fetch === "function", |
| 28 | hasIndexedDB: typeof self.indexedDB !== "undefined", |
| 29 | hasCrypto: typeof self.crypto !== "undefined", |
| 30 | userAgent: self.navigator?.userAgent || "unknown", |
| 31 | }); |
| 32 | |
| 33 | // 5. Message tracing — log all message sizes |
| 34 | const originalPostMessage = self.postMessage; |
| 35 | self.postMessage = function(data, transfer) { |
| 36 | const size = new Blob([JSON.stringify(data)]).size; |
| 37 | console.log(`postMessage: ${size} bytes`); |
| 38 | return originalPostMessage.call(this, data, transfer); |
| 39 | }; |
pro tip
best practice