Web Workers & Concurrency
Run CPU-intensive tasks on background threads using Web Workers, SharedArrayBuffer, and Atomics for true parallel execution without blocking the UI.
Overview
Key Points
- •Workers run in their own thread no shared state with the main thread, no DOM access
- •postMessage sends a structured clone (deep copy); for large data use transferable objects to avoid copying
- •Transferable objects (ArrayBuffer, ImageBitmap, MessagePort) are transferred with zero copy the sender loses access
- •SharedArrayBuffer creates shared memory visible from multiple threads simultaneously
- •Atomics operations are atomic read-modify-write they prevent data races on shared integer arrays
- •Atomics.wait() blocks a worker until notified; Atomics.notify() wakes waiting workers (main thread cannot call wait())
- •Worker threads cannot import ES modules directly in all browsers use importScripts() or module workers with {type:"module"}
- •Service Workers are a special kind of Worker that intercepts network requests and enables offline-first apps
Examples
Basic Worker communication offloading a heavy computation
// worker.js
self.onmessage = function(event) {
const { data } = event;
// Simulate CPU-intensive work (e.g. summing a large array)
let sum = 0;
for (let i = 0; i < data.length; i++) {
sum += data[i];
}
self.postMessage({ sum, length: data.length });
};
// main.js
const worker = new Worker('worker.js');
worker.onmessage = function(event) {
const { sum, length } = event.data;
console.log(`Sum of ${length} numbers: ${sum}`);
};
worker.onerror = function(err) {
console.error('Worker error:', err.message);
};
// Send 1 million numbers to the worker
const data = new Float64Array(1_000_000).map((_, i) => i + 1);
// postMessage with transferable zero-copy transfer
worker.postMessage(data, [data.buffer]); // data.buffer is now transferred
// After transfer: data.byteLength === 0 (sender no longer owns it)After transferring data.buffer, the original Float64Array becomes empty (byteLength 0). The worker now owns the buffer.
Worker pool distributing work across multiple threads
// WorkerPool runs multiple workers and distributes tasks via a queue
class WorkerPool {
#workers = [];
#queue = [];
#idle = [];
constructor(url, size = navigator.hardwareConcurrency) {
for (let i = 0; i < size; i++) {
const worker = new Worker(url);
worker.onmessage = (e) => this.#onResult(worker, e.data);
this.#workers.push(worker);
this.#idle.push(worker);
}
}
run(data, transfer = []) {
return new Promise((resolve, reject) => {
this.#queue.push({ data, transfer, resolve, reject });
this.#dispatch();
});
}
#dispatch() {
while (this.#idle.length && this.#queue.length) {
const worker = this.#idle.pop();
const task = this.#queue.shift();
worker._resolve = task.resolve;
worker.postMessage(task.data, task.transfer);
}
}
#onResult(worker, result) {
worker._resolve(result);
this.#idle.push(worker);
this.#dispatch(); // pick up next queued task
}
}
// Usage
const pool = new WorkerPool('heavy-worker.js', 4);
const tasks = Array.from({ length: 20 }, (_, i) => pool.run({ taskId: i }));
const results = await Promise.all(tasks);
console.log('All tasks done:', results.length); // 20navigator.hardwareConcurrency returns the number of logical CPU cores, giving you a good default pool size.
SharedArrayBuffer and Atomics shared mutable state between threads
// shared-counter.js (worker)
self.onmessage = function({ data: { sab, iterations } }) {
const view = new Int32Array(sab);
for (let i = 0; i < iterations; i++) {
Atomics.add(view, 0, 1); // atomic increment no race condition
}
self.postMessage('done');
};
// main.js
const sab = new SharedArrayBuffer(4); // 4 bytes = 1 int32
const view = new Int32Array(sab);
const workers = Array.from({ length: 4 }, () => new Worker('shared-counter.js'));
let doneCount = 0;
workers.forEach(w => {
w.onmessage = () => {
if (++doneCount === workers.length) {
console.log('Final count:', Atomics.load(view, 0));
// Should be 4 * 100_000 = 400_000 (no lost updates due to Atomics)
}
};
w.postMessage({ sab, iterations: 100_000 });
});
// Without Atomics.add, using view[0]++ would produce race conditions
// and the final count would be less than 400_000SharedArrayBuffer requires cross-origin isolation headers (COOP/COEP) to be enabled on the page for security reasons.
Common Mistakes
- Transferring a buffer (postMessage(data, [data.buffer])) and then trying to read from it the sender loses access immediately after transfer
- Calling Atomics.wait() on the main thread it is not allowed (it would block the event loop); only Worker threads can call Atomics.wait()
- Using SharedArrayBuffer without cross-origin isolation headers browsers block it without COOP and COEP headers
- Spawning a new Worker for every small task Worker startup is expensive; pool workers and reuse them for many tasks
Note
For browser apps, consider using Comlink (a tiny library wrapping the Worker postMessage API) which lets you call Worker methods as if they were async functions on the main thread. It handles the message ID bookkeeping and error propagation that you would otherwise write manually.
Exercises
Write JavaScript code to solve each exercise. Run it and check your output.
Completed: 0 / 5 exercises
Knowledge Check
Question 1 of 4