Difficulty: Advanced
JavaScript runs on a single thread in the browser. Every computation, DOM update, event handler, and animation shares that one thread. When a heavy computation runs - processing a large dataset, parsing a big file, or running a complex algorithm - the entire UI freezes because the main thread is blocked. Web Workers solve this by letting you run JavaScript in a background thread, separate from the main thread.
A Web Worker is created by passing a JavaScript file URL to the Worker constructor: new Worker('worker.js'). The worker runs in an isolated context with no access to the DOM, window object, or document. Communication between the main thread and the worker happens exclusively through the postMessage() method and the onmessage event handler. Data is copied (structured clone algorithm) when transferred, not shared, which prevents race conditions.
The typical workflow is: the main thread creates a worker, sends data to it via postMessage(), the worker processes the data and sends the result back via its own postMessage(), and the main thread receives the result in its onmessage handler. For large data transfers (ArrayBuffers, large arrays), you can use transferable objects to transfer ownership of the data instead of copying it, which is nearly instantaneous regardless of data size.
Dedicated Workers (the most common type) are tied to the script that created them. Only the creating script can communicate with them. Shared Workers can be accessed by multiple scripts across different tabs, iframes, or windows of the same origin. Shared Workers use a port-based communication model and are useful for shared resources like WebSocket connections or caches that should be shared across tabs.
Web Workers have several limitations. They cannot access the DOM (no document, no window.alert). They cannot use some browser APIs (though they can use fetch, setTimeout, IndexedDB, and WebSocket). Each worker has its own global scope (self instead of window). Workers also carry overhead - creating a worker is not free, so do not spawn workers for trivial tasks. The ideal use cases are CPU-intensive computations: image processing, data parsing, sorting large datasets, cryptographic operations, and real-time calculations that would otherwise freeze the UI.
<!-- main.html -->
<button id="run">Calculate Primes</button>
<p id="result">Click to find primes up to 1,000,000</p>
<p id="status">UI stays responsive during calculation</p>
<script>
const worker = new Worker('prime-worker.js');
const btn = document.getElementById('run');
const result = document.getElementById('result');
btn.addEventListener('click', () => {
result.textContent = 'Calculating...';
worker.postMessage({ limit: 1000000 });
});
worker.onmessage = (event) => {
result.textContent =
`Found ${event.data.count} primes in ${event.data.time}ms`;
};
worker.onerror = (error) => {
result.textContent = `Worker error: ${error.message}`;
};
</script>
<!-- prime-worker.js -->
<script>
// This runs in the worker thread
self.onmessage = (event) => {
const { limit } = event.data;
const start = performance.now();
// Sieve of Eratosthenes
const sieve = new Uint8Array(limit + 1).fill(1);
sieve[0] = sieve[1] = 0;
for (let i = 2; i * i <= limit; i++) {
if (sieve[i]) {
for (let j = i * i; j <= limit; j += i) {
sieve[j] = 0;
}
}
}
const count = sieve.reduce((sum, val) => sum + val, 0);
const time = (performance.now() - start).toFixed(1);
self.postMessage({ count, time });
};
</script>
The main thread sends a message to the worker with the limit. The worker runs the Sieve of Eratosthenes (CPU-intensive) without blocking the UI. When done, it posts the result back. The UI remains responsive throughout.
<script>
// Create a worker from a Blob (no separate file needed)
const workerCode = `
self.onmessage = (event) => {
const { array } = event.data;
// Simulate heavy sorting
const sorted = array.slice().sort((a, b) => a - b);
self.postMessage({ sorted });
};
`;
const blob = new Blob([workerCode], { type: 'application/javascript' });
const workerUrl = URL.createObjectURL(blob);
const worker = new Worker(workerUrl);
// Generate random array
const bigArray = Array.from(
{ length: 100000 },
() => Math.floor(Math.random() * 1000000)
);
console.log('Sending array to worker...');
worker.postMessage({ array: bigArray });
worker.onmessage = (event) => {
const { sorted } = event.data;
console.log('First 5:', sorted.slice(0, 5));
console.log('Last 5:', sorted.slice(-5));
URL.revokeObjectURL(workerUrl); // Clean up
worker.terminate(); // Shut down worker
};
</script>
You can create workers without a separate file using Blob and URL.createObjectURL. This is useful in bundled applications. Always call URL.revokeObjectURL and worker.terminate() when done to free resources.
<script>
const worker = new Worker('image-worker.js');
// Create a large ArrayBuffer (simulating image data)
const buffer = new ArrayBuffer(1024 * 1024 * 4); // 4MB
const view = new Uint8Array(buffer);
view.fill(128); // Fill with grey pixels
console.log('Buffer size before transfer:', buffer.byteLength);
// Transfer ownership (zero-copy, near-instant)
worker.postMessage({ pixels: buffer }, [buffer]);
console.log('Buffer size after transfer:', buffer.byteLength);
// Output: 0 - the buffer was transferred, not copied
worker.onmessage = (event) => {
const processed = event.data.pixels;
console.log('Processed buffer size:', processed.byteLength);
// The processed buffer is now owned by the main thread
};
</script>
<!-- image-worker.js -->
<script>
self.onmessage = (event) => {
const pixels = new Uint8Array(event.data.pixels);
// Invert all pixel values
for (let i = 0; i < pixels.length; i++) {
pixels[i] = 255 - pixels[i];
}
// Transfer back
self.postMessage(
{ pixels: pixels.buffer },
[pixels.buffer]
);
};
</script>
The second argument to postMessage is a list of transferable objects. Instead of copying the buffer (slow for large data), ownership is transferred instantly. After transfer, the original buffer becomes empty (byteLength === 0).
<script>
class WorkerPool {
constructor(workerScript, poolSize = navigator.hardwareConcurrency || 4) {
this.workers = [];
this.queue = [];
this.available = [];
for (let i = 0; i < poolSize; i++) {
const worker = new Worker(workerScript);
this.workers.push(worker);
this.available.push(worker);
}
}
run(data) {
return new Promise((resolve, reject) => {
const task = { data, resolve, reject };
if (this.available.length > 0) {
this._dispatch(this.available.shift(), task);
} else {
this.queue.push(task);
}
});
}
_dispatch(worker, task) {
worker.onmessage = (event) => {
task.resolve(event.data);
if (this.queue.length > 0) {
this._dispatch(worker, this.queue.shift());
} else {
this.available.push(worker);
}
};
worker.onerror = (error) => {
task.reject(error);
this.available.push(worker);
};
worker.postMessage(task.data);
}
terminate() {
this.workers.forEach(w => w.terminate());
}
}
// Usage
const pool = new WorkerPool('compute-worker.js', 4);
async function processAll(items) {
const results = await Promise.all(
items.map(item => pool.run(item))
);
console.log('All tasks complete:', results);
pool.terminate();
}
</script>
A worker pool reuses a fixed number of workers across many tasks. Tasks are queued when all workers are busy. This avoids the overhead of creating/destroying workers for each task and limits concurrency to the available CPU cores.
Web Workers, postMessage, Dedicated Workers, Shared Workers, Offloading Computation