Web Workers for Parallel Computation
The Frozen UI Problem
You've written this perfectly reasonable code. A user uploads a CSV file with 500,000 rows, and you parse it:
function handleUpload(file) {
const text = file.text();
const rows = parseCSV(text); // 800ms of CPU work
renderTable(rows);
}
During those 800ms, the browser can't respond to clicks, scrolling stops, animations freeze, and typing feels dead. The user thinks your app crashed. They rage-click the button. Now you're parsing the same CSV three times.
The problem isn't the algorithm. The problem is where it runs.
JavaScript's main thread handles everything: DOM updates, event handlers, layout, paint, garbage collection, and your code. When your code hogs the CPU for 800ms, everything else waits in line.
Think of the main thread as a single-lane road. Everything travels on it: UI updates, user events, your code. A Web Worker is a second road running in parallel. Heavy trucks (expensive computation) can take the worker road, keeping the main road clear for fast traffic (user interactions). The two roads communicate through rest stops (message passing), not direct access.
What Web Workers Actually Are
A Web Worker is a JavaScript execution environment running on a separate OS thread. It has its own event loop, its own global scope (self instead of window), and its own memory. It cannot access the DOM, document, window, or any main-thread API directly.
This is real parallelism, not concurrency. On a multi-core CPU, the worker thread runs on a different core simultaneously with the main thread.
// main.js — spawning a worker
const worker = new Worker('worker.js');
worker.postMessage({ type: 'parse', data: csvText });
worker.onmessage = (event) => {
const rows = event.data;
renderTable(rows);
};
// worker.js — runs on a separate thread
self.onmessage = (event) => {
if (event.data.type === 'parse') {
const rows = parseCSV(event.data.data);
self.postMessage(rows);
}
};
The main thread sends a message, continues handling UI events, and receives the result later. The 800ms parse happens on a background thread. Zero jank.
Worker Lifecycle
Workers aren't free. Each one spins up a new thread, parses and compiles a script, and allocates memory. Understanding the lifecycle helps you decide when to create them and when to reuse them.
The startup cost matters. Creating a worker, fetching and parsing its script, and initializing its environment takes anywhere from 5ms to 50ms+ depending on script size. For a task that takes 10ms of CPU time, spinning up a worker is a net loss.
- 1Reuse workers for repeated tasks — don't create and destroy them per operation
- 2Workers have no DOM access — they communicate exclusively via postMessage
- 3Each worker is a real OS thread with its own memory and event loop
- 4worker.terminate() kills the worker immediately — pending work is lost
- 5Worker startup has overhead (5-50ms+) — only worth it for tasks above ~50ms of CPU time
postMessage and the Structured Clone Algorithm
When you call postMessage(data), the browser doesn't send a pointer to data. It creates a deep copy using the Structured Clone Algorithm. This is safe — neither thread can corrupt the other's memory — but it has a cost.
// Main thread sends a large array
const bigArray = new Float64Array(1_000_000); // 8MB
worker.postMessage(bigArray); // Cloned! Now 16MB total in memory
The structured clone algorithm handles most JavaScript types: objects, arrays, Map, Set, Date, RegExp, ArrayBuffer, Blob, File, ImageData, even circular references. But it cannot clone functions, DOM nodes, Error objects (partially), or class instances with methods (they lose their prototype).
What Gets Cloned vs What Doesn't
// These clone fine:
worker.postMessage({
name: 'Alice',
scores: [95, 87, 92],
metadata: new Map([['role', 'admin']]),
created: new Date(),
buffer: new ArrayBuffer(16),
});
// These throw or lose data:
worker.postMessage({
handler: () => {}, // DataCloneError — functions can't be cloned
element: document.body, // DataCloneError — DOM nodes can't be cloned
instance: new MyClass(), // Clones as plain object — methods are lost
});
For small messages (a few KB), the clone cost is negligible. For large data (megabytes of pixel data, huge JSON objects), the copy time becomes the bottleneck you were trying to avoid.
Transferable Objects: Move, Don't Copy
When you transfer an ArrayBuffer, you're not copying it — you're moving it. The source thread loses access, and the destination thread gets ownership. Zero-copy. Instant.
const pixels = new Uint8ClampedArray(4096 * 4096 * 4); // 64MB of RGBA data
// Without transfer: copies 64MB (slow, doubles memory)
worker.postMessage(pixels.buffer);
// With transfer: moves 64MB (instant, no extra memory)
worker.postMessage(pixels.buffer, [pixels.buffer]);
// After transfer, the source buffer is neutered:
console.log(pixels.buffer.byteLength); // 0 — gone
console.log(pixels.length); // 0 — the view is empty
The second argument to postMessage is a list of Transferable objects. These get moved instead of cloned. After the transfer, the original reference becomes an empty, zero-length buffer. You cannot read or write to it anymore.
Transferable types include ArrayBuffer, MessagePort, ReadableStream, WritableStream, TransformStream, ImageBitmap, OffscreenCanvas, and VideoFrame.
// Real pattern: process an image in a worker
// main.js
const bitmap = await createImageBitmap(file);
const offscreen = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = offscreen.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
// Transfer the underlying buffer — zero copy
worker.postMessage(
{ width: bitmap.width, height: bitmap.height, data: imageData.data.buffer },
[imageData.data.buffer]
);
Under the hood, transferring an ArrayBuffer updates the internal pointer in V8. The source ArrayBuffer's backing store pointer is set to null and its byte length to zero. The destination gets the original pointer. No memcpy happens. This is why it's O(1) regardless of buffer size — transferring 1KB and 1GB take the same time.
SharedArrayBuffer and Atomics: True Shared Memory
Transferable objects solve the copy problem, but they move data — only one thread can access it at a time. SharedArrayBuffer lets multiple threads read and write the same memory simultaneously.
This is powerful and dangerous. You're entering lock-free concurrent programming territory.
// main.js
const shared = new SharedArrayBuffer(1024); // 1KB shared between threads
const view = new Int32Array(shared);
const worker1 = new Worker('worker.js');
const worker2 = new Worker('worker.js');
// Both workers get access to the SAME memory
worker1.postMessage({ buffer: shared, id: 0 });
worker2.postMessage({ buffer: shared, id: 1 });
// worker.js
self.onmessage = (event) => {
const { buffer, id } = event.data;
const view = new Int32Array(buffer);
// Both workers can read/write view[0] at the same time
// Without synchronization, this is a data race!
view[0] += 1; // NOT atomic — read + modify + write = race condition
};
The view[0] += 1 line looks atomic but it isn't. It's three operations: read the current value, add 1, write the result. If two threads do this simultaneously, one increment gets lost.
Atomics: Synchronization Primitives
The Atomics object provides thread-safe operations on SharedArrayBuffer:
// worker.js — safe increment
self.onmessage = (event) => {
const view = new Int32Array(event.data.buffer);
// Atomic add — guaranteed to be indivisible
Atomics.add(view, 0, 1);
// Other atomic operations:
Atomics.load(view, 0); // read without tearing
Atomics.store(view, 0, 42); // write without tearing
Atomics.compareExchange(view, 0, 42, 99); // CAS operation
Atomics.exchange(view, 0, 100); // swap and return old value
};
Wait and Notify: Thread Coordination
Atomics.wait() blocks a worker thread until another thread signals it. This is how you build mutexes, semaphores, and producer-consumer patterns.
// worker.js — waits for the main thread to signal
const view = new Int32Array(event.data.buffer);
// Block this thread until view[0] is no longer 0
// (Cannot call Atomics.wait on the main thread — it would freeze the UI)
const result = Atomics.wait(view, 0, 0); // 'ok', 'not-equal', or 'timed-out'
// main.js — signals the worker
Atomics.store(view, 0, 1);
Atomics.notify(view, 0, 1); // Wake up 1 waiting thread
SharedArrayBuffer requires specific HTTP headers due to Spectre mitigations. Your server must send Cross-Origin-Opener-Policy: same-origin and Cross-Origin-Embedder-Policy: require-corp. Without these headers, the SharedArrayBuffer constructor throws.
Comlink: Making Workers Feel Like Regular Functions
Raw postMessage gets painful fast. You end up with message type strings, event handlers, manual serialization, and callback spaghetti. Comlink (by the Chrome team) wraps workers in a Proxy so you can call worker functions like they're local async functions.
// worker.js
import * as Comlink from 'comlink';
const api = {
parseCSV(text) {
return heavyParse(text); // runs on worker thread
},
async processImage(buffer) {
return applyFilters(buffer);
},
};
Comlink.expose(api);
// main.js
import * as Comlink from 'comlink';
const worker = new Worker('worker.js', { type: 'module' });
const api = Comlink.wrap(worker);
// Looks like a regular async function call!
const rows = await api.parseCSV(csvText);
const result = await api.processImage(buffer);
No postMessage. No onmessage. No message type switching. Comlink handles serialization, deserialization, and return values through MessageChannel internally.
Transferring with Comlink
Comlink supports Transferable objects through Comlink.transfer():
// Transfer an ArrayBuffer through Comlink
const result = await api.processImage(
Comlink.transfer(buffer, [buffer])
);
Callbacks Across the Thread Boundary
Comlink even supports passing callbacks from the main thread to the worker using Comlink.proxy():
// main.js — pass a progress callback to the worker
await api.processLargeFile(
fileData,
Comlink.proxy((progress) => {
progressBar.style.width = `${progress}%`;
})
);
// worker.js
async processLargeFile(data, onProgress) {
for (let i = 0; i < chunks.length; i++) {
processChunk(chunks[i]);
onProgress(((i + 1) / chunks.length) * 100);
}
}
Module Workers
Classic workers use importScripts() for dependencies — a synchronous, unscoped, globally-polluting mechanism from 2009. Module workers use ES modules instead:
const worker = new Worker('worker.js', { type: 'module' });
// worker.js — now you can use import/export
import { parseCSV } from './csv-parser.js';
import { validate } from './validator.js';
self.onmessage = (event) => {
const validated = validate(event.data);
const rows = parseCSV(validated);
self.postMessage(rows);
};
Module workers support import statements, top-level await, and tree-shaking. They're the modern approach and work in all major browsers. When using bundlers like Vite or webpack, you get automatic code-splitting and dependency resolution.
// Vite/webpack pattern — inline worker with bundler support
const worker = new Worker(
new URL('./worker.js', import.meta.url),
{ type: 'module' }
);
SharedWorker: One Worker, Many Tabs
A SharedWorker is a single worker instance shared across all tabs/windows of the same origin. If your user has five tabs open, they all share one worker instead of spinning up five.
// Any tab can connect
const shared = new SharedWorker('shared-worker.js');
shared.port.onmessage = (event) => {
console.log('Received:', event.data);
};
shared.port.postMessage({ action: 'subscribe', channel: 'updates' });
// shared-worker.js
const ports = [];
self.onconnect = (event) => {
const port = event.ports[0];
ports.push(port);
port.onmessage = (event) => {
if (event.data.action === 'broadcast') {
ports.forEach(p => p.postMessage(event.data.payload));
}
};
};
SharedWorkers are useful for cross-tab state synchronization, shared WebSocket connections, and shared caches. But browser support is uneven — Safari only added support recently, and Chrome for Android doesn't support them. For cross-tab communication, BroadcastChannel is often simpler.
Real-World Use Cases
Image Processing
The most common and effective use case. Image operations are CPU-intensive, operate on ArrayBuffer data (perfect for transfer), and have no DOM dependency.
// worker.js — grayscale filter
self.onmessage = (event) => {
const { data, width, height } = event.data;
const pixels = new Uint8ClampedArray(data);
for (let i = 0; i < pixels.length; i += 4) {
const gray = pixels[i] * 0.299 + pixels[i + 1] * 0.587 + pixels[i + 2] * 0.114;
pixels[i] = gray; // R
pixels[i + 1] = gray; // G
pixels[i + 2] = gray; // B
// pixels[i + 3] is alpha — leave it
}
self.postMessage(pixels.buffer, [pixels.buffer]); // transfer back
};
Search Indexing
Building a search index (like Pagefind or Lunr) involves tokenizing, stemming, and building inverted indices. All CPU-bound, all perfectly suited for a worker.
// worker.js
import { Index } from 'flexsearch';
let index;
self.onmessage = (event) => {
if (event.data.type === 'build') {
index = new Index({ tokenize: 'forward' });
event.data.documents.forEach((doc, i) => {
index.add(i, doc.content);
});
self.postMessage({ type: 'ready' });
}
if (event.data.type === 'search') {
const results = index.search(event.data.query, { limit: 20 });
self.postMessage({ type: 'results', data: results });
}
};
Heavy JSON Parsing
JSON.parse() is synchronous and runs on the main thread. A 5MB JSON response blocks the thread for 50-100ms. Move it to a worker.
// worker.js
self.onmessage = (event) => {
const parsed = JSON.parse(event.data);
self.postMessage(parsed);
};
// main.js
const response = await fetch('/api/huge-dataset');
const text = await response.text();
worker.postMessage(text);
WASM Computation
WebAssembly modules (image codecs, crypto, physics engines) are CPU-intensive by design. Running them in a worker keeps the main thread free.
// worker.js
const wasmModule = await WebAssembly.instantiateStreaming(
fetch('/codec.wasm')
);
self.onmessage = (event) => {
const { inputBuffer } = event.data;
const input = new Uint8Array(inputBuffer);
const outputPtr = wasmModule.instance.exports.encode(input, input.length);
const output = new Uint8Array(
wasmModule.instance.exports.memory.buffer,
outputPtr,
wasmModule.instance.exports.getOutputSize()
);
const result = output.slice().buffer;
self.postMessage(result, [result]);
};
When NOT to Use Workers
Workers have overhead. Don't reach for them reflexively.
The Serialization Tax
Every postMessage clones the data (unless you transfer). If your computation takes 5ms but serializing and deserializing the input/output takes 20ms, you've made things slower.
// Bad: worker overhead exceeds computation
worker.postMessage({ items: smallArray }); // 3ms to clone
// Worker computes for 2ms
// Result cloned back: 3ms
// Total: 8ms (vs 2ms on main thread)
Tasks Too Small for Workers
If the computation takes less than ~50ms, the overhead of message passing likely outweighs the benefit. Quick calculations, simple string operations, basic array transformations — keep these on the main thread.
DOM-Dependent Operations
If the result needs to interact with the DOM at every step (reading layout, updating styles progressively), workers can't help. You'd need to constantly postMessage back and forth, which defeats the purpose.
When You Need Synchronous Results
Workers are inherently asynchronous. If your code structure requires a synchronous return value, a worker can't provide one (at least not without SharedArrayBuffer + Atomics.wait, which is complex and usually the wrong approach).
| What developers do | What they should do |
|---|---|
| Creating a new Worker for every single computation Worker creation involves thread spawning, script fetching, parsing, and initialization — 5-50ms+ per worker. Amortize this cost by reusing workers. | Create workers at app startup and reuse them with a message protocol |
| Sending large objects via postMessage without transfer Structured clone copies all data. A 100MB ArrayBuffer gets duplicated in memory and takes time to copy. Transfer moves it in O(1) with zero memory overhead. | Use Transferable objects for ArrayBuffers and other transferable types |
| Using SharedArrayBuffer when postMessage with transfer is sufficient SharedArrayBuffer requires COOP/COEP headers, introduces race condition risks, and needs Atomics for synchronization. Transfer is simpler and safer for most cases. | Use SharedArrayBuffer only when multiple threads need simultaneous read/write access |
| Moving a 5ms computation to a worker to avoid blocking The serialization, message passing, and deserialization overhead can easily exceed 5-10ms. For short tasks, the worker makes performance worse, not better. | Only offload tasks that take 50ms+ of CPU time to workers |
| Using importScripts() in workers instead of module workers importScripts is synchronous, pollutes the global scope, and doesn't support tree-shaking. Module workers support import/export, top-level await, and integrate with bundlers. | Use new Worker(url, { type: 'module' }) for ES module support |
Worker Pool Pattern
For CPU-intensive tasks that arrive frequently (image thumbnails, data processing pipelines), a worker pool distributes work across a fixed number of workers:
class WorkerPool {
#workers;
#queue;
#available;
constructor(url, size = navigator.hardwareConcurrency || 4) {
this.#workers = Array.from({ length: size }, () => new Worker(url, { type: 'module' }));
this.#queue = [];
this.#available = [...this.#workers];
}
exec(data, transfer = []) {
return new Promise((resolve, reject) => {
const task = { data, transfer, resolve, reject };
if (this.#available.length > 0) {
this.#dispatch(this.#available.pop(), task);
} else {
this.#queue.push(task);
}
});
}
#dispatch(worker, task) {
worker.onmessage = (event) => {
task.resolve(event.data);
this.#release(worker);
};
worker.onerror = (event) => {
task.reject(new Error(event.message));
this.#release(worker);
};
worker.postMessage(task.data, task.transfer);
}
#release(worker) {
if (this.#queue.length > 0) {
this.#dispatch(worker, this.#queue.shift());
} else {
this.#available.push(worker);
}
}
terminate() {
this.#workers.forEach(w => w.terminate());
}
}
// Usage
const pool = new WorkerPool('/image-worker.js', 4);
const results = await Promise.all(
images.map(img => pool.exec(img.buffer, [img.buffer]))
);
The pool size should match navigator.hardwareConcurrency (number of logical CPU cores). Creating more workers than cores leads to thread contention and context-switching overhead.
Error Handling in Workers
Worker errors don't propagate to the main thread automatically. Unhandled errors in workers silently disappear unless you listen for them.
// main.js
worker.onerror = (event) => {
console.error(`Worker error: ${event.message}`);
console.error(` at ${event.filename}:${event.lineno}`);
event.preventDefault(); // Prevent default error logging
};
worker.onmessageerror = (event) => {
console.error('Failed to deserialize worker message');
};
For structured error handling with Comlink, errors thrown in the worker automatically reject the proxy's promise:
// worker.js
const api = {
processData(data) {
if (!data) throw new TypeError('Data is required');
return compute(data);
},
};
Comlink.expose(api);
// main.js
try {
const result = await api.processData(null);
} catch (err) {
// TypeError: Data is required — propagated from worker
console.error(err.message);
}
Performance Checklist
Dedicated vs Shared vs Service Workers
| Feature | Dedicated Worker | SharedWorker | Service Worker |
|---|---|---|---|
| Threads | One per new Worker() call | One shared across tabs | One per scope (origin + path) |
| Access | Creating page only | All same-origin pages | All same-origin pages |
| Lifecycle | Tied to creating page | Stays alive while any tab connects | Independent — survives tab close |
| Primary use | Heavy computation | Cross-tab state sharing | Offline caching, push notifications |
| DOM access | None | None | None |
| Communication | postMessage | MessagePort | postMessage + Fetch events |
- 1Use Dedicated Workers for CPU-bound computation — they are the simplest and most widely supported
- 2Use SharedWorkers only when multiple tabs need to share a single connection or state
- 3Workers cannot access the DOM — always verify your libraries are DOM-free before offloading
- 4Transfer ArrayBuffers instead of cloning them for large binary data
- 5SharedArrayBuffer requires COOP and COEP headers — plan your deployment accordingly
- 6Comlink eliminates postMessage boilerplate — use it in production codebases
- 7Pool size should match navigator.hardwareConcurrency to avoid thread contention