|$ curl https://forge-ai.dev/api/markdown?path=docs/html/web-workers
$cat docs/web-workers.md
updated Recently·25 min read·published

Web Workers

HTMLWeb WorkersConcurrencyAdvanced
Introduction

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.

Dedicated Workers

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 / EventDescription
new Worker(url)Creates a new dedicated worker from a script URL
worker.postMessage(data)Sends data to the worker (structured clone)
worker.onmessageReceives messages from the worker
worker.onerrorHandles 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.onmessageWorker receives messages from the main thread
self.close()Worker terminates itself
dedicated-worker.js
JavaScript
1// === main.js (main thread) ===
2
3const worker = new Worker("worker.js");
4
5worker.postMessage({ type: "compute", payload: { n: 42 } });
6
7worker.onmessage = (e) => {
8 console.log("Received from worker:", e.data);
9 document.getElementById("result").textContent = e.data.result;
10};
11
12worker.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
18function stopWorker() {
19 worker.terminate();
20 console.log("Worker terminated");
21}
22
23// === worker.js (worker thread) ===
24
25self.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
34function 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

Worker scripts must be served from the same origin as the page or with appropriate CORS headers. In production, bundle worker scripts as separate files. For development, you can use new Worker(new URL("./worker.js", import.meta.url)) with module workers in supporting browsers.
Worker Scope

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 WorkerNot Available
self, onmessage, postMessagewindow, document, parent
importScripts()DOM manipulation APIs
fetch(), XMLHttpRequestlocalStorage, sessionStorage
setTimeout, setIntervalalert, confirm, prompt
WebSocket, IndexedDBcanvas (direct access)
OffscreenCanvas, WebAssemblyWorker nested within worker (DedicatedWorker only)
Crypto, Performance, ConsoleFileReader, Blob (available)
worker-scope.js
JavaScript
1// === worker.js — full scope capabilities ===
2
3// Available APIs
4self.console.log("Worker initialized");
5
6// HTTP requests
7self.fetch("/api/data")
8 .then((r) => r.json())
9 .then((data) => self.postMessage(data));
10
11// Timers
12const intervalId = setInterval(() => {
13 self.postMessage({ type: "heartbeat" });
14}, 1000);
15
16// IndexedDB access
17const request = indexedDB.open("MyDatabase", 1);
18
19// importScripts — load external scripts synchronously
20self.importScripts("library.js", "utils.js");
21// All functions from library.js and utils.js are now available
22
23// WebAssembly
24async 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
32self.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
41function shutdown() {
42 clearInterval(intervalId);
43 self.close();
44}
Transferable Objects

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 TypeDescription
ArrayBufferRaw binary data buffer (most common)
MessagePortChannel messaging port
OffscreenCanvasCanvas rendering context for worker
ReadableStreamStream data without copying
WritableStreamWritable stream endpoint
WebAssembly.ModuleCompiled WebAssembly module
transferable.js
JavaScript
1// === Without transfer: copy (slow for large data) ===
2
3const largeBuffer = new ArrayBuffer(1024 * 1024 * 100); // 100MB
4worker.postMessage({ data: largeBuffer });
5// This copies 100MB — expensive and blocks the main thread
6
7// === With transfer: zero-copy (fast) ===
8
9const largeBuffer2 = new ArrayBuffer(1024 * 1024 * 100);
10worker.postMessage({ data: largeBuffer2 }, [largeBuffer2]);
11// Ownership transferred — no copy, instant return
12// After this, largeBuffer2.byteLength === 0 (neutered)
13
14// Multiple transferables
15const buf1 = new ArrayBuffer(1024 * 1024);
16const buf2 = new ArrayBuffer(1024 * 1024);
17worker.postMessage({ a: buf1, b: buf2 }, [buf1, buf2]);
18
19// === Image processing with transfer ===
20
21async 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 ===
44self.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

After transferring an ArrayBuffer, the original reference becomes neutered — its byteLength becomes 0 and any attempt to read it throws an error. Always transfer in one direction at a time, and transfer back if the worker needs to return data. Transferable objects are ideal for image processing, audio buffers, WebSocket data, and WebAssembly memory.
Shared Workers

Shared workers can be accessed by multiple scripts running in different windows, iframes, or same-origin documents. They communicate via ports and the connect event.

shared-worker.js
JavaScript
1// === tab1.js (first connection) ===
2
3const sharedWorker = new SharedWorker("shared-worker.js");
4sharedWorker.port.start();
5
6sharedWorker.port.postMessage({ type: "subscribe", channel: "updates" });
7
8sharedWorker.port.onmessage = (e) => {
9 console.log("Tab 1 received:", e.data);
10};
11
12// === tab2.js (second connection — same worker) ===
13
14const sharedWorker = new SharedWorker("shared-worker.js");
15sharedWorker.port.start();
16
17sharedWorker.port.postMessage({ type: "getState" });
18
19sharedWorker.port.onmessage = (e) => {
20 console.log("Tab 2 received:", e.data);
21};
22
23// === shared-worker.js ===
24
25const connections = new Map();
26let sharedState = { count: 0 };
27
28self.onconnect = (e) => {
29 const port = e.ports[0];
30 const id = crypto.randomUUID();
31 connections.set(id, port);
32
33 port.postMessage({ type: "connected", id });
34
35 port.onmessage = (e) => {
36 const { type, channel, data } = e.data;
37
38 switch (type) {
39 case "subscribe":
40 console.log(`Client ${id} subscribed to ${channel}`);
41 port.postMessage({ type: "subscribed", channel });
42 break;
43
44 case "getState":
45 port.postMessage({ type: "state", data: sharedState });
46 break;
47
48 case "update":
49 sharedState = { ...sharedState, ...data };
50 // Broadcast to all connected ports
51 connections.forEach((p) => {
52 p.postMessage({ type: "stateUpdate", data: sharedState });
53 });
54 break;
55
56 case "disconnect":
57 connections.delete(id);
58 port.close();
59 break;
60 }
61 };
62
63 port.onmessageerror = (e) => {
64 console.error("Deserialization error:", e);
65 };
66};
67
68// Clean up disconnected ports
69self.onbeforeunload = () => {
70 connections.clear();
71};

info

Shared workers live as long as there is at least one active port connection. Unlike dedicated workers, they persist across page navigations within the same origin. Always call port.start() explicitly — it does not start automatically when using the onmessage property in some browsers. Use Shared Workers for real-time collaboration, shared caching, and cross-tab state synchronization.
Use Cases

Web Workers excel at CPU-intensive operations that would otherwise block the UI thread. These are the most common and effective use cases:

Use CaseWorker TypeWhy Worker?
Heavy ComputationDedicatedFibonacci, prime factors, crypto, data analysis
Image ProcessingDedicated + TransferableFilters, compression, OCR, color correction
Real-time DataDedicatedWebSocket parsing, market data, sensor data
File ParsingDedicatedCSV/JSON parsing, XML processing, large files
Audio / VideoDedicatedAudio analysis, video frame processing
Cross-tab SyncSharedReal-time collaboration, shared state
Data CompressionDedicatedGzip, Brotli compression in the background
compute-worker.js
JavaScript
1// === main.js — heavy computation offloaded ===
2
3function 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
15document.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
24self.onmessage = (e) => {
25 const { limit } = e.data;
26 const primes = sieveOfEratosthenes(limit);
27 self.postMessage({ primes });
28};
29
30function 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):

preview
Limitations

Workers have important constraints that affect architecture decisions:

LimitationImpactWorkaround
No DOM accessCannot manipulate HTMLSend results to main thread for rendering
No window/documentCannot access globals like location, navigatorPass needed values via postMessage
No localStorage/sessionStorageCannot persist data directlyUse IndexedDB or postMessage to main thread
Same-origin policyWorker script must match page originUse CORS headers or blob URLs for inline workers
Serialization overheadpostMessage uses structured cloneUse transferable objects for large data
Startup costWorker creation has overheadReuse workers with a pool pattern
Limited APIsNo canvas (direct), no WebAudio, no FileReaderUse OffscreenCanvas, transfer buffers
Debugging complexitySeparate console, breakpoints need DevToolsChrome DevTools has dedicated Worker panel

best practice

Not every task needs a worker. The overhead of creating a worker and serializing data may outweigh the benefit for small operations. A good rule of thumb: if the task takes less than 10ms, run it on the main thread. Profile before and after to measure the actual benefit. Use workers for operations that would cause visible jank (frame drops, input lag).
Debugging Workers

Debugging workers requires browser DevTools support. Chrome and Firefox provide dedicated worker inspection panels.

debugging.js
JavaScript
1// === Debugging techniques ===
2
3// 1. Console logging — appears in worker's own console context
4self.console.log("Worker state:", { status: "ready", config });
5
6// 2. Error handling — catch and report errors
7self.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
17self.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
25self.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
34const originalPostMessage = self.postMessage;
35self.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

In Chrome DevTools, open the "Sources" panel and look for "Threads" in the drawer. You can set breakpoints directly in worker scripts. Messages appear in the worker's own console context (select it from the "JavaScript contexts" dropdown). Use console.profile() and console.time() to profile worker performance.
Best Practices
Use transferable objects (ArrayBuffer, OffscreenCanvas) for large data to avoid serialization overhead
Reuse workers where possible — creation has overhead; implement a worker pool for parallel tasks
Handle errors explicitly with onerror in both main thread and worker scope
Always terminate workers when done to free memory (worker.terminate() or self.close())
Bundle worker scripts separately in production builds (not in the main bundle)
Use module workers (type: 'module') for ES module support in modern browsers
Set reasonable timeouts for worker operations and handle non-responsive workers
Prefer message event listeners over onmessage property to allow multiple handlers
Use SharedWorker for cross-tab state synchronization and resource sharing
Profile before and after — not all tasks benefit from being offloaded to a worker
Fall back to main thread execution when workers are unavailable (rare, but possible in some contexts)
Avoid synchronous operations in workers (they block the worker thread just like the main thread)

best practice

The most impactful use of workers is isolating heavy computation from UI interactions. A common pattern: create a worker pool (e.g., 2-4 workers based on navigator.hardwareConcurrency) and dispatch tasks round-robin. Use transferable objects for any payload over 100KB. Always provide a fallback for environments that do not support workers (some enterprise browsers, certain web views).
$Blueprint — Engineering Documentation·Section ID: HTML-36·Revision: 1.0