JavaScript Web Workers: Run Code Off the Main Thread
Learn how to use JavaScript Web Workers to run CPU-intensive code off the main thread, keeping your UI responsive with practical examples and patterns.
What you'll learn
- ✓How Web Workers run JavaScript on separate threads
- ✓Communication patterns between main thread and workers
- ✓Transferable objects for zero-copy data passing
- ✓SharedWorker and Worker pool patterns for real applications
Prerequisites
- •JavaScript fundamentals
- •Basic understanding of async programming
The browser’s main thread handles everything: JavaScript execution, DOM updates, layout calculations, painting, and user input. When a heavy computation blocks the main thread, the page freezes. Web Workers solve this by letting you run JavaScript on a separate OS thread, completely parallel to the main thread.
Creating a Basic Worker
A Web Worker runs in its own global scope with no access to the DOM, window, or document. Communication happens through message passing.
// main.js
const worker = new Worker("worker.js");
worker.postMessage({ numbers: [1, 2, 3, 4, 5] });
worker.onmessage = (event) => {
console.log("Result:", event.data);
};
worker.onerror = (error) => {
console.error("Worker error:", error.message);
};
// worker.js
self.onmessage = (event) => {
const { numbers } = event.data;
const sum = numbers.reduce((a, b) => a + b, 0);
self.postMessage(sum);
};
The postMessage API serializes data using the structured clone algorithm, which handles most JavaScript types including objects, arrays, Maps, Sets, Dates, RegExps, ArrayBuffers, and even circular references. It does not handle functions, DOM nodes, or Error objects.
Inline Workers with Blob URLs
You can create workers without a separate file using Blob URLs:
function createInlineWorker(fn) {
const blob = new Blob(
[`self.onmessage = function(e) { (${fn.toString()})(e); }`],
{ type: "application/javascript" }
);
const url = URL.createObjectURL(blob);
const worker = new Worker(url);
const originalTerminate = worker.terminate.bind(worker);
worker.terminate = () => {
URL.revokeObjectURL(url);
originalTerminate();
};
return worker;
}
const sortWorker = createInlineWorker((event) => {
const sorted = event.data.slice().sort((a, b) => a - b);
self.postMessage(sorted);
});
sortWorker.postMessage([5, 3, 8, 1, 9]);
sortWorker.onmessage = (e) => console.log(e.data);
Promise-Based Worker Communication
The raw postMessage/onmessage API is callback-based. Wrapping it in Promises makes it much more ergonomic:
function createPromiseWorker(workerUrl) {
const worker = new Worker(workerUrl);
const pending = new Map();
let nextId = 0;
worker.onmessage = (event) => {
const { id, result, error } = event.data;
const promise = pending.get(id);
if (!promise) return;
pending.delete(id);
if (error) {
promise.reject(new Error(error));
} else {
promise.resolve(result);
}
};
return {
send(data) {
const id = nextId++;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
worker.postMessage({ id, data });
});
},
terminate() {
worker.terminate();
for (const { reject } of pending.values()) {
reject(new Error("Worker terminated"));
}
pending.clear();
},
};
}
// promise-worker.js
self.onmessage = (event) => {
const { id, data } = event.data;
try {
const result = processData(data);
self.postMessage({ id, result });
} catch (err) {
self.postMessage({ id, error: err.message });
}
};
Now the main thread can use await:
const worker = createPromiseWorker("promise-worker.js");
const result = await worker.send({ task: "fibonacci", n: 40 });
console.log(result);
Transferable Objects
When you postMessage an ArrayBuffer, the data is cloned by default. For large buffers, this clone can be expensive. Transferable objects move ownership instead of copying:
const buffer = new ArrayBuffer(1024 * 1024 * 100); // 100 MB
// Slow: clones 100 MB
worker.postMessage({ buffer });
// Fast: transfers ownership, zero copy
worker.postMessage({ buffer }, [buffer]);
console.log(buffer.byteLength); // 0 - the buffer is now empty
After transfer, the original buffer becomes detached and unusable on the sending side. This is by design since two threads should not share mutable memory without synchronization.
Types that can be transferred:
ArrayBufferMessagePortReadableStreamWritableStreamTransformStreamImageBitmapOffscreenCanvas
async function processImage(imageUrl) {
const response = await fetch(imageUrl);
const blob = await response.blob();
const bitmap = await createImageBitmap(blob);
worker.postMessage({ bitmap }, [bitmap]);
}
Worker Pool Pattern
Creating a new Worker for every task is expensive. A worker pool reuses a fixed number of workers:
class WorkerPool {
#workers = [];
#queue = [];
constructor(workerUrl, size = navigator.hardwareConcurrency || 4) {
for (let i = 0; i < size; i++) {
const worker = new Worker(workerUrl);
this.#workers.push({ worker, busy: false });
}
}
exec(data) {
return new Promise((resolve, reject) => {
const task = { data, resolve, reject };
const idle = this.#workers.find(w => !w.busy);
if (idle) {
this.#runTask(idle, task);
} else {
this.#queue.push(task);
}
});
}
#runTask(entry, task) {
entry.busy = true;
entry.worker.onmessage = (event) => {
task.resolve(event.data);
this.#completeTask(entry);
};
entry.worker.onerror = (error) => {
task.reject(error);
this.#completeTask(entry);
};
entry.worker.postMessage(task.data);
}
#completeTask(entry) {
entry.busy = false;
if (this.#queue.length > 0) {
const nextTask = this.#queue.shift();
this.#runTask(entry, nextTask);
}
}
terminate() {
this.#workers.forEach(({ worker }) => worker.terminate());
}
}
const pool = new WorkerPool("compute-worker.js", 4);
const results = await Promise.all([
pool.exec({ task: "hash", data: chunk1 }),
pool.exec({ task: "hash", data: chunk2 }),
pool.exec({ task: "hash", data: chunk3 }),
pool.exec({ task: "hash", data: chunk4 }),
pool.exec({ task: "hash", data: chunk5 }),
pool.exec({ task: "hash", data: chunk6 }),
]);
With 4 workers and 6 tasks, the first 4 tasks run in parallel. As each finishes, the next queued task starts immediately.
SharedWorker
A SharedWorker is shared across all tabs and iframes from the same origin. This is useful for shared state, connection pooling, or cross-tab communication:
// shared-worker.js
const connections = new Set();
self.onconnect = (event) => {
const port = event.ports[0];
connections.add(port);
port.onmessage = (event) => {
for (const conn of connections) {
conn.postMessage({
from: event.data.tabId,
message: event.data.message,
});
}
};
port.start();
};
// main.js (same code in every tab)
const shared = new SharedWorker("shared-worker.js");
const tabId = crypto.randomUUID();
shared.port.onmessage = (event) => {
console.log(`Tab ${event.data.from} says: ${event.data.message}`);
};
shared.port.start();
shared.port.postMessage({ tabId, message: "Hello from new tab" });
Real-World Use Cases
Off-Main-Thread Search
Searching through large datasets without blocking the UI:
// search-worker.js
let dataset = null;
self.onmessage = (event) => {
if (event.data.type === "init") {
dataset = event.data.items;
self.postMessage({ type: "ready" });
return;
}
if (event.data.type === "search") {
const query = event.data.query.toLowerCase();
const results = dataset.filter(item =>
item.title.toLowerCase().includes(query) ||
item.description.toLowerCase().includes(query)
);
self.postMessage({
type: "results",
query: event.data.query,
results: results.slice(0, 50),
});
}
};
// main.js
const searchWorker = new Worker("search-worker.js");
searchWorker.postMessage({ type: "init", items: allProducts });
const searchInput = document.getElementById("search");
searchInput.addEventListener("input", (e) => {
searchWorker.postMessage({
type: "search",
query: e.target.value,
});
});
searchWorker.onmessage = (event) => {
if (event.data.type === "results") {
renderResults(event.data.results);
}
};
CSV Parsing
Parsing large CSV files without freezing the UI:
// csv-worker.js
self.onmessage = (event) => {
const text = event.data;
const lines = text.split("\n");
const headers = lines[0].split(",").map(h => h.trim());
const rows = [];
for (let i = 1; i < lines.length; i++) {
if (!lines[i].trim()) continue;
const values = lines[i].split(",");
const row = {};
headers.forEach((header, index) => {
row[header] = values[index]?.trim();
});
rows.push(row);
if (i % 10000 === 0) {
self.postMessage({
type: "progress",
percent: Math.round((i / lines.length) * 100),
});
}
}
self.postMessage({ type: "complete", rows });
};
What Workers Cannot Do
Workers operate in a restricted environment:
- No DOM access (
document,window, DOM APIs) - No
alert(),confirm(), orprompt() - Limited
localStorageaccess (useindexedDBinstead) - Same-origin restriction on worker scripts
Workers can use:
fetchandXMLHttpRequestindexedDBWebSocketssetTimeoutandsetIntervalcryptoimportScripts()for loading additional scripts- Other Workers (nested workers)
Wrapping Up
Web Workers let you move CPU-intensive tasks off the main thread, keeping your UI responsive. Use the promise-based wrapper pattern for ergonomic communication, transferable objects for large binary data, and worker pools for parallel task processing. The main thread should handle DOM updates and user interaction, while workers handle computation, parsing, and data processing. Start with a single worker for your heaviest operation and expand to a pool as needed.
Related articles
- JavaScript JavaScript Event Loop Explained: How Async Code Really Works
Understand how the JavaScript event loop handles async operations including the call stack, microtasks, macrotasks, and execution order with practical examples.
- JavaScript SharedArrayBuffer and Atomics for Parallel JavaScript
Learn how to use SharedArrayBuffer and Atomics to share memory between threads, coordinate Web Workers, and build lock-free data structures in JavaScript.
- JavaScript How to Find and Fix JavaScript Memory Leaks
Learn how to detect, diagnose, and fix JavaScript memory leaks using Chrome DevTools heap snapshots, allocation timelines, and common leak patterns.
- JavaScript JavaScript Streams API: Process Data Incrementally
Master the JavaScript Streams API to process large files, network responses, and data pipelines incrementally without loading everything into memory.