Web Workers: True Parallelism in the Browser
JavaScript is single-threaded - every computation blocks the UI thread. Web Workers run scripts in background threads, enabling true parallelism for CPU-intensive work without freezing the browser.
The Problem: Main Thread Saturation
// This freezes the UI for several seconds
function processLargeDataset(data) {
return data.map(item => heavyComputation(item)); // Blocks UI!
}
// Users see:
// - Frozen scrolling
// - Unresponsive buttons
// - 'Page Unresponsive' dialog
// Chrome DevTools: Long Task > 50ms = jank
Basic Worker Pattern
// worker.js - runs in separate thread
self.onmessage = function(event) {
const { type, data } = event.data;
if (type === 'PROCESS') {
const result = heavyComputation(data);
self.postMessage({ type: 'RESULT', result });
}
};
function heavyComputation(data) {
// CPU-intensive work here - won't block UI
return data.map(x => x * x).filter(x => x % 2 === 0).reduce((a, b) => a + b, 0);
}
// main.js
const worker = new Worker('/worker.js');
worker.onmessage = ({ data }) => {
if (data.type === 'RESULT') {
displayResult(data.result);
}
};
worker.onerror = (error) => {
console.error('Worker error:', error.message);
worker.terminate();
};
worker.postMessage({ type: 'PROCESS', data: largeArray });
Transferable Objects: Zero-Copy Data Transfer
By default, postMessage copies data (expensive for large buffers). Transfer ownership instead:
// BAD: Copies 100MB buffer (takes ~100ms)
worker.postMessage({ buffer: largeArrayBuffer });
// GOOD: Transfers ownership (takes ~0.1ms, but buffer unusable in sender)
worker.postMessage({ buffer: largeArrayBuffer }, [largeArrayBuffer]);
// largeArrayBuffer.byteLength === 0 after transfer!
// Pattern: transfer back when done
// worker.js
self.onmessage = ({ data }) => {
const { buffer } = data;
const view = new Float32Array(buffer);
for (let i = 0; i < view.length; i++) view[i] *= 2.0;
self.postMessage({ buffer }, [buffer]); // Transfer back
};
Worker Pool for Parallelism
class WorkerPool {
constructor(workerScript, poolSize = navigator.hardwareConcurrency) {
this.workers = Array.from({ length: poolSize }, () =>
new Worker(workerScript)
);
this.queue = [];
this.idle = [...this.workers];
this.workers.forEach(w => w.onmessage = this._onMessage.bind(this));
}
execute(data, transferables = []) {
return new Promise((resolve, reject) => {
const task = { data, transferables, resolve, reject };
if (this.idle.length > 0) {
this._dispatch(task);
} else {
this.queue.push(task);
}
});
}
_dispatch(task) {
const worker = this.idle.pop();
worker._currentTask = task;
worker.postMessage(task.data, task.transferables);
}
_onMessage({ target: worker, data }) {
worker._currentTask.resolve(data);
if (this.queue.length > 0) {
this._dispatch(this.queue.shift());
} else {
this.idle.push(worker);
}
}
async processAll(items) {
return Promise.all(items.map(item => this.execute(item)));
}
terminate() { this.workers.forEach(w => w.terminate()); }
}
const pool = new WorkerPool('/image-processor.js', 4);
const results = await pool.processAll(imageChunks);
SharedArrayBuffer and Atomics
For true shared memory between threads (requires COOP/COEP headers):
// Shared memory - both main thread and workers read/write this
const sharedBuffer = new SharedArrayBuffer(4 * 1024 * 1024); // 4MB
const sharedArray = new Float32Array(sharedBuffer);
// Atomic counter for work distribution
const counterBuffer = new SharedArrayBuffer(4);
const counter = new Int32Array(counterBuffer);
// Worker: claim a chunk atomically
self.onmessage = ({ data }) => {
const { sharedBuffer, counterBuffer, total } = data;
const arr = new Float32Array(sharedBuffer);
const cnt = new Int32Array(counterBuffer);
const CHUNK_SIZE = 1000;
while (true) {
const start = Atomics.add(cnt, 0, CHUNK_SIZE); // Atomic fetch-and-add
if (start >= total) break;
const end = Math.min(start + CHUNK_SIZE, total);
for (let i = start; i < end; i++) {
arr[i] = Math.sqrt(arr[i]); // Process chunk
}
}
self.postMessage('done');
};
// Atomics.wait/notify for synchronization
const lockBuffer = new SharedArrayBuffer(4);
const lock = new Int32Array(lockBuffer);
// Wait until value changes (worker blocks here)
Atomics.wait(lock, 0, 0); // Waits until lock[0] !== 0
// Signal from main thread
Atomics.store(lock, 0, 1);
Atomics.notify(lock, 0, Infinity); // Wake all waiting workers
Comlink: Ergonomic Worker API
// processor.worker.js
import { expose } from 'comlink';
const api = {
async processImage(imageData) {
// Runs in worker thread
return applyGrayscaleFilter(imageData);
},
async computeHash(data) {
const buffer = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(buffer))
.map(b => b.toString(16).padStart(2, '0')).join('');
}
};
expose(api);
// main.js - looks like regular async function calls!
import { wrap } from 'comlink';
const worker = new Worker('/processor.worker.js', { type: 'module' });
const processor = wrap(worker);
// Call worker methods like regular async functions
const hash = await processor.computeHash(fileBuffer);
const processed = await processor.processImage(imageData);
Real-World Use Cases
// CSV parsing with Papa Parse in worker
// worker.js
importScripts('https://unpkg.com/papaparse/papaparse.min.js');
self.onmessage = ({ data }) => {
const result = Papa.parse(data.csv, { header: true, dynamicTyping: true });
self.postMessage(result.data);
};
// Markdown compilation
// worker.js
import { marked } from 'marked';
self.onmessage = ({ data }) => {
self.postMessage(marked(data.markdown));
};
// Bundle: vite/webpack worker syntax
// main.js
const csvWorker = new Worker(new URL('./csv.worker.js', import.meta.url));
Performance Guidelines
- Use workers for: Tasks >16ms that block the UI, image/video processing, CSV/JSON parsing, cryptography, simulations
- Avoid workers for: DOM manipulation (not allowed), quick operations (<1ms), tasks dominated by network I/O
- Pool size:
navigator.hardwareConcurrencyworkers maximum (usually 4-16) - Message size: Transfer for >10KB buffers; copy for small objects
Web Workers are the right tool for CPU-bound JavaScript. With Comlink, the ergonomic barrier is minimal. Profile first, then offload specific slow paths.