|$ curl https://forge-ai.dev/api/markdown?path=docs/js/web-workers
$cat docs/javascript-—-web-workers.md
updated This week·25 min read·published

JavaScript — Web Workers

JavaScriptWeb WorkersAdvancedAdvanced
Introduction

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.

basic-worker.js
JavaScript
1// Main thread — spawn a worker
2const worker = new Worker('worker.js');
3
4worker.postMessage({ type: 'compute', data: 1000000 });
5
6worker.onmessage = (event) => {
7 console.log('Result from worker:', event.data);
8};
9
10worker.onerror = (error) => {
11 console.error('Worker error:', error.message);
12};
13
14// worker.js — runs in separate thread
15self.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

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" }).

FeatureAvailableNot Available
DOMwindow, document, parent
NetworkXMLHttpRequest, fetch, WebSocket
StorageIndexedDB, Cache API (partial)
TimerssetTimeout, setInterval, setImmediate
ThreadingOwn event loop, no shared memory (by default)
ModulesES modules with { type: "module" }
dedicated-worker.js
JavaScript
1// main.js — creating a dedicated worker
2const worker = new Worker('fibonacci-worker.js');
3
4// Send data to the worker
5worker.postMessage(40);
6
7// Receive results
8worker.onmessage = (e) => {
9 console.log(`Fibonacci result: ${e.data}`);
10 worker.terminate(); // done with the worker
11};
12
13// Handle errors
14worker.onerror = (e) => {
15 console.error(`Worker error at ${e.filename}:${e.lineno}: ${e.message}`);
16};
17
18// fibonacci-worker.js
19function fib(n) {
20 if (n <= 1) return n;
21 return fib(n - 1) + fib(n - 2);
22}
23
24self.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

Workers are expensive to create — each worker spawns a new OS thread with its own V8 isolate. Avoid creating workers for trivial tasks or on every user interaction. A common pattern is to maintain a pool of reusable workers or use a single worker that handles different message types via a routing mechanism.
Transferable Objects

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.

transferable.js
JavaScript
1// Without transfer — structured clone copies the buffer
2const buffer = new ArrayBuffer(1024 * 1024 * 100); // 100MB
3worker.postMessage({ data: buffer });
4// Both threads now have independent 100MB copies — 200MB total
5
6// With transfer — zero-copy ownership transfer
7const bigBuffer = new ArrayBuffer(1024 * 1024 * 100);
8worker.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
13const canvas = document.getElementById('myCanvas');
14const offscreen = canvas.transferControlToOffscreen();
15worker.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
25function 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

Always use transferable objects when moving large binary data (video frames, audio buffers, WebGL textures, file data) between threads. The structured clone algorithm is fast for small objects, but for anything over 1MB, transfer is significantly more efficient. Remember: transferred objects are detached from the sender — plan your data flow accordingly.
preview
Shared Workers

Shared Workers differ from dedicated workers in one crucial way: they can be accessed by multiple scripts running in different windows, iframes, or even same-origin tabs. A single shared worker instance serves all connected contexts. This enables coordination across browsing contexts — shared state, broadcast messaging, and resource pooling.

Shared Workers use the SharedWorker constructor instead of Worker. Communication happens through MessagePort objects — each connection gets its own port. The shared worker uses the connect event to handle new connections, and each port can send/receive messages independently.

shared-worker.js
JavaScript
1// main.js (tab 1) — connecting to shared worker
2const sharedWorker = new SharedWorker('shared-counter.js');
3sharedWorker.port.start();
4
5sharedWorker.port.postMessage({ action: 'increment' });
6sharedWorker.port.onmessage = (e) => {
7 console.log(`Tab 1 count: ${e.data.count}`);
8};
9
10// main.js (tab 2) — same shared worker instance
11const sharedWorker2 = new SharedWorker('shared-counter.js');
12sharedWorker2.port.start();
13sharedWorker2.port.postMessage({ action: 'increment' });
14sharedWorker2.port.onmessage = (e) => {
15 console.log(`Tab 2 count: ${e.data.count}`);
16};
17
18// shared-counter.js — shared worker implementation
19let count = 0;
20const connections = [];
21
22self.onconnect = (e) => {
23 const port = e.ports[0];
24 connections.push(port);
25
26 port.onmessage = (event) => {
27 const { action } = event.data;
28 switch (action) {
29 case 'increment':
30 count++;
31 broadcast({ count, action: 'updated' });
32 break;
33 case 'reset':
34 count = 0;
35 broadcast({ count, action: 'reset' });
36 break;
37 case 'get':
38 port.postMessage({ count, action: 'current' });
39 break;
40 }
41 };
42
43 port.start();
44};
45
46function broadcast(msg) {
47 connections.forEach(port => port.postMessage(msg));
48}

warning

Shared Workers have stricter same-origin requirements than dedicated workers and are not supported in all browsers (notably Safari has limited support). They also require careful lifecycle management — if all connections close, the shared worker is terminated. Use them for coordination across tabs (e.g., real-time collaboration, shared cache, tab synchronization).
Worker Communication

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.

worker-communication.js
JavaScript
1// Request-response pattern — simulating a remote procedure call
2const worker = new Worker('worker-rpc.js');
3
4function 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
20const result = await rpc('fibonacci', [40]);
21console.log('RPC result:', result);
22
23// worker-rpc.js
24const 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
34self.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
45const channel = new MessageChannel();
46worker.postMessage({ port: channel.port1 }, [channel.port1]);
47channel.port2.onmessage = (e) => {
48 console.log('Direct message from worker:', e.data);
49};
50channel.port2.postMessage('ping from main thread');
Error Handling

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.

error-handling.js
JavaScript
1// Main thread — listening for worker errors
2const worker = new Worker('faulty-worker.js');
3
4// Error event — for runtime errors/Exceptions
5worker.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
13worker.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
23self.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
36self.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
45const 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

Always implement a structured error protocol with your workers. The onerror event only catches uncaught exceptions — it does not handle rejected promises, invalid messages, or expected failure modes. Use a consistent envelope format like { id, result, error } for all worker messages to distinguish successful results from errors.
Best Practices
Use workers only for CPU-intensive tasks — not every async operation needs a worker
Minimize message passing frequency — batch updates instead of streaming individual values
Use transferable objects for large payloads (ArrayBuffer, OffscreenCanvas) to avoid cloning overhead
Implement worker pooling for repeated parallel tasks — creating workers is expensive
Always handle errors both via onerror and a structured message protocol
Set worker timeouts to detect and recover from hung workers gracefully
Terminate workers when they are no longer needed to free memory and threads
Use ES module workers ({ type: 'module' }) for cleaner code organization
Avoid shared mutable state — use message passing as the sole communication mechanism
Test workers in isolation — mock the postMessage interface for unit testing
🔥

pro tip

The Worker thread has no access to the DOM, but it can use OffscreenCanvas for rendering, WebAssembly for near-native performance, and fetch for network I/O. For maximum parallelism, consider using navigator.hardwareConcurrency to determine the optimal number of workers for your device.
$Blueprint — Engineering Documentation·Section ID: JS-WEBWORKERS·Revision: 1.0