A Web Worker runs a script on a separate thread. It can't touch the DOM, but it can do heavy computation — parsing big files, image processing, statistics — while the main thread keeps the page smooth.
The page and the worker communicate by sending messages with postMessage and listening for message events. Data is copied between them (structured clone); large binary buffers can be transferred instead of copied.
Wrap the message exchange in a Promise so calling the worker feels like calling an async function. Create module workers with new Worker(url, { type: "module" }).
// Runs on a background thread: no DOM access here
self.addEventListener("message", (event) => {
const { id, scores } = event.data;
const sorted = scores.toSorted((a, b) => a - b);
const mean = sorted.reduce((a, b) => a + b, 0) / sorted.length;
const median = sorted[Math.floor(sorted.length / 2)];
const variance = sorted.reduce((sum, s) => sum + (s - mean) ** 2, 0) / sorted.length;
self.postMessage({ id, mean, median, sd: Math.sqrt(variance), count: sorted.length });
});<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Workers</title>
<script type="module" src="main.js"></script>
</head>
<body>
<button id="run">Analyse 3 million scores</button>
<p id="ticker">0</p>
<pre id="result"></pre>
</body>
</html>const worker = new Worker(new URL("./stats-worker.js", import.meta.url));
const pending = new Map();
let nextId = 1;
worker.addEventListener("message", (event) => {
const { id, ...result } = event.data;
pending.get(id)?.(result);
pending.delete(id);
});
function analyse(scores) {
return new Promise((resolve) => {
const id = nextId++;
pending.set(id, resolve);
worker.postMessage({ id, scores });
});
}
// A ticker proves the main thread stays free while the worker computes
let ticks = 0;
setInterval(() => { document.querySelector("#ticker").textContent = ++ticks; }, 50);
document.querySelector("#run").addEventListener("click", async () => {
const scores = Array.from({ length: 3_000_000 }, () => Math.floor(Math.random() * 101));
const started = performance.now();
const stats = await analyse(scores);
document.querySelector("#result").textContent =
`count ${stats.count}\nmean ${stats.mean.toFixed(2)}\nmedian ${stats.median}\nsd ${stats.sd.toFixed(2)}\n` +
`took ${Math.round(performance.now() - started)}ms`;
});Key points
- Workers run CPU-heavy code off the main thread; they cannot access the DOM.
- Communicate with
postMessage; wrap requests in Promises for clean async APIs. - Transfer large ArrayBuffers instead of copying them.
Exercise
Send the scores as a Float64Array and transfer its buffer (postMessage(msg, [array.buffer])). Compare the time with the plain-array version, and add a "Cancel" button that calls worker.terminate() and creates a new worker.
Show solution
Try the exercise yourself first — then compare your approach with this one.
A Float64Array's buffer can be transferred: listing it in the second argument of postMessage moves the memory to the worker instead of copying 24 MB. Cancel terminates the worker immediately, rejects the waiting Promise, and creates a fresh worker for the next run.
self.addEventListener("message", ({ data }) => {
const { id, buffer } = data;
const scores = new Float64Array(buffer);
scores.sort();
let sum = 0;
for (const s of scores) sum += s;
self.postMessage({ id, count: scores.length, mean: sum / scores.length, median: scores[scores.length >> 1] });
});<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Transferable worker</title>
<script type="module" src="main.js"></script>
</head>
<body>
<button id="run">Analyse 3 million scores</button>
<button id="cancel">Cancel</button>
<pre id="result"></pre>
</body>
</html>let worker;
let pending = null;
let nextId = 1;
const result = document.querySelector("#result");
function startWorker() {
worker = new Worker(new URL("./stats-worker.js", import.meta.url));
worker.addEventListener("message", ({ data }) => {
if (pending?.id === data.id) pending.resolve(data);
pending = null;
});
}
startWorker();
function analyse(scores) {
return new Promise((resolve, reject) => {
pending = { id: nextId++, resolve, reject };
worker.postMessage({ id: pending.id, buffer: scores.buffer }, [scores.buffer]); // transfer, don't copy
});
}
document.querySelector("#run").addEventListener("click", async () => {
const scores = Float64Array.from({ length: 3_000_000 }, () => Math.floor(Math.random() * 101));
const started = performance.now();
result.textContent = "Working...";
try {
const stats = await analyse(scores);
console.log("buffer after transfer:", scores.byteLength); // 0: the memory moved to the worker
result.textContent = `count ${stats.count}, mean ${stats.mean.toFixed(2)}, median ${stats.median}, ${Math.round(performance.now() - started)} ms`;
} catch (err) {
result.textContent = err.message;
}
});
document.querySelector("#cancel").addEventListener("click", () => {
worker.terminate();
pending?.reject(new Error("Cancelled"));
pending = null;
startWorker();
});