Running JavaScript on a background thread so parsing, filtering, or crunching data does not freeze the UI. The main thread should stay responsive; workers handle the grunt work.
Why leave the main thread?
The browser does layout, paint, and JavaScript on the main thread. Long synchronous tasks block input, animation, and scrolling. If sorting 50,000 rows takes 800ms on the main thread, the page feels broken. The same work in a worker keeps the UI usable.
Workers cannot touch the DOM. They receive messages, compute, and post results back. That constraint is the boundary – plan your architecture accordingly.
Dedicated worker basics
// worker.js
self.onmessage = (event) => {
const { items, query } = event.data;
const filtered = items.filter((item) =>
item.label.toLowerCase().includes(query.toLowerCase())
);
self.postMessage({ type: 'result', filtered });
};
// main.js
const worker = new Worker('./worker.js', { type: 'module' });
worker.onmessage = (event) => {
if (event.data.type === 'result') {
renderRows(event.data.filtered);
}
};
function search(items, query) {
worker.postMessage({ items, query });
}
Use { type: 'module' } when the worker needs imports. Serve over HTTP – file:// often breaks worker loading, same as ES modules.
Structured cloning and transferables
postMessage copies data by default (structured clone). Large arrays are duplicated, which costs time and memory. Transferables move ownership without copying – ideal for ArrayBuffer.
const buffer = new Float64Array(1_000_000).buffer;
worker.postMessage({ buffer }, [buffer]);
// main thread no longer owns buffer
For big payloads, prefer transferables or process data in chunks rather than shipping the whole data set on every keystroke.
A small worker pool
One worker handles one job at a time. For parallel work, use a pool and queue tasks.
export function createWorkerPool(url, size = navigator.hardwareConcurrency || 4) {
const workers = Array.from({ length: size }, () => new Worker(url, { type: 'module' }));
const queue = [];
let index = 0;
workers.forEach((worker) => {
worker.onmessage = (event) => {
const job = worker.currentJob;
worker.currentJob = null;
job.resolve(event.data);
runNext(worker);
};
});
function runNext(worker) {
const job = queue.shift();
if (!job) return;
worker.currentJob = job;
worker.postMessage(job.payload);
}
return {
run(payload) {
return new Promise((resolve, reject) => {
const job = { payload, resolve, reject };
const idle = workers.find((w) => !w.currentJob);
if (idle) {
idle.currentJob = job;
idle.postMessage(payload);
} else {
queue.push(job);
}
});
},
terminate() {
workers.forEach((w) => w.terminate());
},
};
}
SharedWorker and service workers
SharedWorker can be shared across tabs from the same origin – useful for a single WebSocket connection. Browser support is patchy; check caniuse before betting the farm on it.
Service workers are workers with a different job: intercept network requests and cache assets. Covered properly in the PWA tutorial. Do not confuse them with dedicated workers for compute.
Error handling and termination
worker.onerror = (event) => {
console.error('Worker failed', event.message);
};
worker.onmessageerror = () => {
console.error('Worker sent something that could not be cloned');
};
// when the feature unmounts or the tab navigates away
worker.terminate();
Always terminate workers you no longer need. Orphan workers keep consuming memory until the tab closes.
Good candidates for workers
- Filtering or sorting large in-memory lists.
- Parsing CSV, JSON lines, or log files.
- Image or audio processing (with OffscreenCanvas where supported).
- Expensive regex or validation over many records.
- Updating three DOM nodes – keep that on the main thread.
The next tutorial looks at Streams – processing large data chunk by chunk instead of loading everything into memory at once.

