JavaScript — Web Workers
Web Workers are a browser API that enable JavaScript to run background scripts in separate threads, independent of the main UI thread. JavaScript is single-threaded by nature, meaning long-running computations can block the user interface and make the page unresponsive. Web Workers solve this by offloading heavy tasks to isolated threads that communicate with the main thread via message passing.
Workers operate in a separate global context (DedicatedWorkerGlobalScope or SharedWorkerGlobalScope) with no access to the DOM, window, document, or parent object. They have their own event loop and can use setTimeout, fetch, IndexedDB, and WebAssembly. Communication with the main thread happens exclusively through the postMessage API.
Three types of workers exist: Dedicated Workers (1:1 with the main thread), Shared Workers (shared across multiple tabs/windows), and Service Workers (network proxy for offline caching and push notifications). This guide focuses on dedicated and shared workers for computational parallelism.
| 1 | // Main thread — spawn a worker |
| 2 | const worker = new Worker('worker.js'); |
| 3 | |
| 4 | worker.postMessage({ type: 'compute', data: 1000000 }); |
| 5 | |
| 6 | worker.onmessage = (event) => { |
| 7 | console.log('Result from worker:', event.data); |
| 8 | }; |
| 9 | |
| 10 | worker.onerror = (error) => { |
| 11 | console.error('Worker error:', error.message); |
| 12 | }; |
| 13 | |
| 14 | // worker.js — runs in separate thread |
| 15 | self.onmessage = (event) => { |
| 16 | const { type, data } = event.data; |
| 17 | if (type === 'compute') { |
| 18 | let sum = 0; |
| 19 | for (let i = 0; i < data; i++) { |
| 20 | sum += Math.sqrt(i); |
| 21 | } |
| 22 | self.postMessage({ result: sum }); |
| 23 | } |
| 24 | }; |
Dedicated Workers are the most common type of Web Worker. Each dedicated worker is linked to exactly one parent document (the script that created it). When the parent document is closed, the worker is terminated. The worker communicates with the parent via postMessage and listens for messages using the onmessage event handler.
Workers are created by passing the URL of a JavaScript file to the Worker constructor. The worker file executes in its own global scope — use self or this to access the worker global object. Workers can import additional scripts using importScripts() or ES module imports (with { type: "module" }).
| Feature | Available | Not Available |
|---|---|---|
| DOM | ✕ | window, document, parent |
| Network | ✓ | XMLHttpRequest, fetch, WebSocket |
| Storage | ✓ | IndexedDB, Cache API (partial) |
| Timers | ✓ | setTimeout, setInterval, setImmediate |
| Threading | ✓ | Own event loop, no shared memory (by default) |
| Modules | ✓ | ES modules with { type: "module" } |
| 1 | // main.js — creating a dedicated worker |
| 2 | const worker = new Worker('fibonacci-worker.js'); |
| 3 | |
| 4 | // Send data to the worker |
| 5 | worker.postMessage(40); |
| 6 | |
| 7 | // Receive results |
| 8 | worker.onmessage = (e) => { |
| 9 | console.log(`Fibonacci result: ${e.data}`); |
| 10 | worker.terminate(); // done with the worker |
| 11 | }; |
| 12 | |
| 13 | // Handle errors |
| 14 | worker.onerror = (e) => { |
| 15 | console.error(`Worker error at ${e.filename}:${e.lineno}: ${e.message}`); |
| 16 | }; |
| 17 | |
| 18 | // fibonacci-worker.js |
| 19 | function fib(n) { |
| 20 | if (n <= 1) return n; |
| 21 | return fib(n - 1) + fib(n - 2); |
| 22 | } |
| 23 | |
| 24 | self.onmessage = (e) => { |
| 25 | const result = fib(e.data); |
| 26 | self.postMessage(result); |
| 27 | }; |
| 28 | |
| 29 | // Alternative: importScripts for larger codebases |
| 30 | // importScripts('helper.js', 'math-utils.js'); |
| 31 | |
| 32 | // ES module worker (browser support varies) |
| 33 | // const moduleWorker = new Worker('module-worker.js', { type: 'module' }); |
info
When passing data between the main thread and a worker, the default behavior is structured cloning — the data is serialized, copied, and deserialized on the other side. For large data (e.g., ArrayBuffer, ImageBitmap, MessagePort), this copy is expensive. Transferable objects allow zero-copy transfer of ownership between threads: the sender loses access to the data after transfer.
Use the postMessage second argument — an array of objects to transfer. The transferred object becomes null or detached in the sending context. Transferable types include ArrayBuffer, MessagePort, ImageBitmap, OffscreenCanvas, and ReadableStream.
| 1 | // Without transfer — structured clone copies the buffer |
| 2 | const buffer = new ArrayBuffer(1024 * 1024 * 100); // 100MB |
| 3 | worker.postMessage({ data: buffer }); |
| 4 | // Both threads now have independent 100MB copies — 200MB total |
| 5 | |
| 6 | // With transfer — zero-copy ownership transfer |
| 7 | const bigBuffer = new ArrayBuffer(1024 * 1024 * 100); |
| 8 | worker.postMessage({ data: bigBuffer }, [bigBuffer]); |
| 9 | // bigBuffer is now detached — cannot be used in main thread |
| 10 | // console.log(bigBuffer.byteLength); // 0 — detached! |
| 11 | |
| 12 | // Canvas offscreen rendering with transfer |
| 13 | const canvas = document.getElementById('myCanvas'); |
| 14 | const offscreen = canvas.transferControlToOffscreen(); |
| 15 | worker.postMessage({ canvas: offscreen }, [offscreen]); |
| 16 | |
| 17 | // The worker now owns the canvas and can render to it |
| 18 | // self.onmessage = (e) => { |
| 19 | // const canvas = e.data.canvas; |
| 20 | // const ctx = canvas.getContext('2d'); |
| 21 | // // render directly to the canvas from the worker thread |
| 22 | // }; |
| 23 | |
| 24 | // Checking if an object is transferable |
| 25 | function isTransferable(obj) { |
| 26 | try { |
| 27 | const test = obj.constructor.of(0); |
| 28 | new Worker('data:,').postMessage(test, [test]); |
| 29 | return true; |
| 30 | } catch { |
| 31 | return false; |
| 32 | } |
| 33 | } |
best practice
Worker communication is built on message passing. The postMessage / onmessage pattern is the foundation, but several advanced patterns enable complex communication architectures. Workers can also create subworkers, use MessageChannel for direct port-to-port communication, and leverage BroadcastChannel for cross-tab messaging.
| 1 | // Request-response pattern — simulating a remote procedure call |
| 2 | const worker = new Worker('worker-rpc.js'); |
| 3 | |
| 4 | function rpc(method, params) { |
| 5 | return new Promise((resolve, reject) => { |
| 6 | const id = crypto.randomUUID(); |
| 7 | const handler = (e) => { |
| 8 | if (e.data.id === id) { |
| 9 | worker.removeEventListener('message', handler); |
| 10 | if (e.data.error) reject(new Error(e.data.error)); |
| 11 | else resolve(e.data.result); |
| 12 | } |
| 13 | }; |
| 14 | worker.addEventListener('message', handler); |
| 15 | worker.postMessage({ id, method, params }); |
| 16 | }); |
| 17 | } |
| 18 | |
| 19 | // Usage |
| 20 | const result = await rpc('fibonacci', [40]); |
| 21 | console.log('RPC result:', result); |
| 22 | |
| 23 | // worker-rpc.js |
| 24 | const handlers = { |
| 25 | fibonacci: (n) => { |
| 26 | if (n <= 1) return n; |
| 27 | return handlers.fibonacci(n - 1) + handlers.fibonacci(n - 2); |
| 28 | }, |
| 29 | heavyComputation: (data) => { |
| 30 | return data.map(x => Math.sqrt(x * x * Math.PI)); |
| 31 | } |
| 32 | }; |
| 33 | |
| 34 | self.onmessage = async (e) => { |
| 35 | const { id, method, params } = e.data; |
| 36 | try { |
| 37 | const result = await handlers[method](...params); |
| 38 | self.postMessage({ id, result }); |
| 39 | } catch (error) { |
| 40 | self.postMessage({ id, error: error.message }); |
| 41 | } |
| 42 | }; |
| 43 | |
| 44 | // MessageChannel — direct port-to-port |
| 45 | const channel = new MessageChannel(); |
| 46 | worker.postMessage({ port: channel.port1 }, [channel.port1]); |
| 47 | channel.port2.onmessage = (e) => { |
| 48 | console.log('Direct message from worker:', e.data); |
| 49 | }; |
| 50 | channel.port2.postMessage('ping from main thread'); |
Error handling in Web Workers requires attention because errors occur in a different thread. Uncaught exceptions in a worker fire the onerror event on the main thread's worker object, while unhandled promise rejections within the worker need their own handler. Workers can also explicitly communicate errors via postMessage as part of a structured protocol.
| 1 | // Main thread — listening for worker errors |
| 2 | const worker = new Worker('faulty-worker.js'); |
| 3 | |
| 4 | // Error event — for runtime errors/Exceptions |
| 5 | worker.onerror = (event) => { |
| 6 | console.error(`Error in ${event.filename}:${event.lineno}`); |
| 7 | console.error(` Message: ${event.message}`); |
| 8 | // Prevent default browser error dialog |
| 9 | event.preventDefault(); |
| 10 | }; |
| 11 | |
| 12 | // Message event — structured error protocol |
| 13 | worker.onmessage = (event) => { |
| 14 | if (event.data.error) { |
| 15 | console.error('Worker reported error:', event.data.error); |
| 16 | // Handle gracefully — retry, fallback, or notify user |
| 17 | return; |
| 18 | } |
| 19 | console.log('Worker result:', event.data.result); |
| 20 | }; |
| 21 | |
| 22 | // Faulty worker — self-contained error handling |
| 23 | self.onmessage = async (e) => { |
| 24 | try { |
| 25 | const result = await riskyOperation(e.data); |
| 26 | self.postMessage({ result }); |
| 27 | } catch (err) { |
| 28 | // Send error back to main thread |
| 29 | self.postMessage({ error: err.message, stack: err.stack }); |
| 30 | // Or re-throw to trigger onerror on the main thread |
| 31 | // throw err; |
| 32 | } |
| 33 | }; |
| 34 | |
| 35 | // Unhandled promise rejection in worker |
| 36 | self.addEventListener('unhandledrejection', (event) => { |
| 37 | console.error('Unhandled rejection in worker:', event.reason); |
| 38 | self.postMessage({ |
| 39 | error: `Unhandled rejection: ${event.reason.message}` |
| 40 | }); |
| 41 | event.preventDefault(); |
| 42 | }); |
| 43 | |
| 44 | // Worker timeout — handle stuck workers |
| 45 | const timeout = setTimeout(() => { |
| 46 | console.warn('Worker taking too long, terminating...'); |
| 47 | worker.terminate(); |
| 48 | // Optionally spawn a new worker as fallback |
| 49 | }, 5000); |
best practice
pro tip