Web Workers
JavaScript in a web page runs on one main thread. The same thread handles your code, clicks, typing, scrolling and painting the screen. While a long calculation runs, nothing else can happen: buttons stop responding and animations freeze. You saw this in How Asynchronous Code Works: async code does not help, because the work still runs on the main thread.
A Web Worker runs a separate script on a background thread. The page stays smooth while the worker does the heavy lifting, and the two talk by sending messages. In this lesson you will create a worker, exchange messages, transfer large data cheaply, handle errors, and learn when a worker is worth it.
Creating a worker
A worker's code lives in its own JavaScript file. The page creates the worker and sends it a message; the worker does the job and posts a reply:
// main.js (runs on the page)
const worker = new Worker("prime-worker.js");
worker.addEventListener("message", (event) => {
console.log("Worker says:", event.data);
});
worker.postMessage(2_000_000); // send the job// prime-worker.js (runs on a background thread)
self.addEventListener("message", (event) => {
const limit = event.data;
const count = countPrimes(limit); // heavy work
self.postMessage(count); // send the answer back
});Inside a worker, self refers to the worker's own global scope (there is no window). Both sides use the same two tools: postMessage(data) to send, and a message event listener to receive. The data arrives in event.data.
With new Worker("file.js", { type: "module" }) the worker becomes a module, so it can use import and export like the rest of your code (see Modules). Module workers work in all current browsers.
Seeing the difference
The Try-it editor runs a single page, so this example keeps the worker code in a <script> element with a made-up type (so the browser does not run it on the page), turns it into a Blob and creates the worker from a temporary URL. In a real project you would use a separate file as shown above.
Click each button and try to type in the box while it works:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Web worker demo</title>
<style>
.spinner { inline-size: 30px; block-size: 30px; border: 4px solid #ddd; border-top-color: #c99a00; border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { rotate: 1turn; } }
</style>
</head>
<body>
<div class="spinner"></div>
<p><input placeholder="Type here while it works"></p>
<button type="button" id="main">Count primes on the main thread</button>
<button type="button" id="bg">Count primes in a worker</button>
<pre id="log"></pre>
<script type="text/worker" id="worker-code">
function countPrimes(limit) {
let count = 0;
for (let n = 2; n <= limit; n++) {
let isPrime = true;
for (let d = 2; d * d <= n; d++) {
if (n % d === 0) { isPrime = false; break; }
}
if (isPrime) count++;
}
return count;
}
self.addEventListener("message", (event) => {
const started = performance.now();
const count = countPrimes(event.data);
self.postMessage({ count, ms: Math.round(performance.now() - started) });
});
</script>
<script>
const LIMIT = 2_000_000;
const log = (text) => (document.querySelector("#log").textContent += text + "\n");
// The same function again, so the page can run it for comparison
function countPrimes(limit) {
let count = 0;
for (let n = 2; n <= limit; n++) {
let isPrime = true;
for (let d = 2; d * d <= n; d++) {
if (n % d === 0) { isPrime = false; break; }
}
if (isPrime) count++;
}
return count;
}
document.querySelector("#main").addEventListener("click", () => {
const started = performance.now();
const count = countPrimes(LIMIT); // the page freezes here
log(`Main thread: ${count} primes in ${Math.round(performance.now() - started)} ms`);
});
const workerSource = document.querySelector("#worker-code").textContent;
const blob = new Blob([workerSource], { type: "text/javascript" });
const worker = new Worker(URL.createObjectURL(blob));
worker.addEventListener("message", (event) => {
log(`Worker: ${event.data.count} primes in ${event.data.ms} ms`);
});
document.querySelector("#bg").addEventListener("click", () => {
log("Job sent to the worker...");
worker.postMessage(LIMIT);
});
</script>
</body>
</html>After clicking both buttons, the log shows something like this (the times depend on your computer):
Main thread: 148933 primes in 1840 ms Job sent to the worker... Worker: 148933 primes in 1795 ms
The answer and the time are about the same. The difference is what you feel: during the main-thread run the spinner stops and the text box ignores your typing; during the worker run, both keep working. A worker does not make code faster. It keeps the page responsive while slow code runs.
What a worker can and cannot use
A worker is a separate JavaScript environment with its own global scope. It shares no variables with the page.
| Available in a worker | Not available in a worker |
|---|---|
fetch, WebSocket, setTimeout, setInterval | document, window and every DOM element |
IndexedDB, crypto, performance | localStorage and sessionStorage |
importScripts() (classic workers) and import (module workers) | alert() and other page dialogs |
OffscreenCanvas for drawing | Page variables and functions |
Because there is no DOM, a worker never updates the page itself. It sends results back, and the main thread puts them on screen.
Messages are copied
postMessage does not share objects. The browser makes a deep copy using the structured clone algorithm, the same one behind structuredClone() from JSON. Objects, arrays, strings, numbers, Map, Set, Date, Blob and typed arrays can be sent. Functions, DOM elements and class methods cannot: trying to send a function throws a DataCloneError. Class instances arrive as plain objects.
Because the receiver gets a copy, changing it does not change the sender's original. For small messages this copying is cheap. For large data, such as a 50 MB image, copying takes time and doubles the memory used.
Transferable objects
Some objects can be transferred instead of copied. Ownership moves to the other side in an instant, without copying, and the sender can no longer use them. An ArrayBuffer (raw binary memory, see Typed Arrays, Blobs & Base64) is the most common example. List the objects to transfer in the second argument of postMessage:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Transferring a buffer</title>
</head>
<body>
<pre id="log"></pre>
<script type="text/worker" id="worker-code">
self.addEventListener("message", (event) => {
const pixels = new Uint8Array(event.data);
for (let i = 0; i < pixels.length; i++) pixels[i] = 255 - pixels[i]; // invert
self.postMessage(event.data, [event.data]); // transfer it back
});
</script>
<script>
const log = (text) => (document.querySelector("#log").textContent += text + "\n");
const source = document.querySelector("#worker-code").textContent;
const worker = new Worker(URL.createObjectURL(new Blob([source], { type: "text/javascript" })));
const buffer = new ArrayBuffer(8 * 1024 * 1024); // 8 MB
new Uint8Array(buffer).fill(10);
log(`Before sending: ${buffer.byteLength} bytes`);
worker.postMessage(buffer, [buffer]); // transfer, do not copy
log(`After sending: ${buffer.byteLength} bytes (the page gave it away)`);
worker.addEventListener("message", (event) => {
const result = new Uint8Array(event.data);
log(`Back from worker: ${event.data.byteLength} bytes, first value ${result[0]}`);
worker.terminate();
});
</script>
</body>
</html>Before sending: 8388608 bytes After sending: 0 bytes (the page gave it away) Back from worker: 8388608 bytes, first value 245
After the transfer, the page's buffer is detached: its length is 0 and it cannot be read. The worker inverted the bytes (255 − 10 = 245) and transferred the buffer back the same way. MessagePort, ImageBitmap and OffscreenCanvas can also be transferred.
Errors and stopping a worker
If the worker's code throws an error that it does not catch, the page receives an error event on the worker object. Call event.preventDefault() in the listener if you have handled it yourself. A messageerror event fires if a message arrives that cannot be decoded.
worker.addEventListener("error", (event) => {
console.error(`Worker failed: ${event.message} (line ${event.lineno})`);
});A worker keeps running, using memory, until it is stopped. Call worker.terminate() from the page when you no longer need it, or self.close() from inside the worker. Terminating is immediate: any job in progress is thrown away.
Advanced: a promise-based helper
Messages are events, but most code is easier to write with await. A small helper can give each job an id and resolve a promise when the matching answer comes back:
const worker = new Worker("prime-worker.js", { type: "module" });
const pending = new Map();
let nextId = 1;
worker.addEventListener("message", (event) => {
const { id, result } = event.data;
pending.get(id)?.resolve(result);
pending.delete(id);
});
function runJob(input) {
const id = nextId++;
const { promise, resolve } = Promise.withResolvers();
pending.set(id, { resolve });
worker.postMessage({ id, input });
return promise;
}
const count = await runJob(2_000_000);The worker must reply with { id, result }. Promise.withResolvers() comes from Promises. Libraries such as Comlink do this wiring for you, so you can call worker functions almost as if they were local.
When to use a worker
Use a worker for CPU-heavy work that would block the page for more than about 50 milliseconds: processing images or audio, parsing a large CSV or JSON file, compression, encryption, searching a big list as the user types, or physics in a game.
Do not use a worker for network requests (fetch is already asynchronous), for small jobs (starting a worker and copying messages has a cost), or for anything that must touch the DOM.
Other kinds of workers exist for other jobs. A SharedWorker can be shared by several tabs of the same site. A Service Worker sits between your site and the network to make pages work offline and receive push notifications; it is a different tool with a different purpose.
Common mistakes
- Trying to use
documentin a worker. It does not exist; send the result to the page and update the DOM there. - Sending a function or DOM element.
postMessagethrows aDataCloneError. - Expecting shared objects. The worker gets a copy; changes do not flow back unless you send them.
- Using a transferred buffer. After a transfer it is detached with length 0.
- Creating a new worker for every small task. Create one, reuse it, and
terminate()it when finished.
Key points
- Workers run JavaScript on a background thread so the page stays responsive.
new Worker(url)creates one;postMessage()and themessageevent carry data both ways.- Workers have no DOM,
windoworlocalStorage, but they do havefetch, timers and IndexedDB. - Messages are copied with the structured clone algorithm; functions cannot be sent.
- Transfer an
ArrayBufferby listing it inpostMessage(data, [buffer])to move it without copying. - Use workers for heavy computation, not for network requests or tiny jobs.
Exercise
Write a page with a <textarea> and a "Count words" button. When clicked, send the text to a worker. The worker should reply with an object containing the number of words, the number of characters, and the five most common words (lower case). Show the result on the page. Create the worker from a Blob, as in the lesson, so it runs in the Try-it editor.
Show solution
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Word counter</title>
</head>
<body>
<textarea id="text" rows="6" cols="50">the train to Pune was late and the train to Jaipur was on time</textarea>
<p><button type="button" id="count">Count words</button></p>
<pre id="result"></pre>
<script type="text/worker" id="worker-code">
self.addEventListener("message", (event) => {
const words = event.data.toLowerCase().match(/[\p{L}\p{N}']+/gu) ?? [];
const counts = new Map();
for (const word of words) counts.set(word, (counts.get(word) ?? 0) + 1);
const top = [...counts].sort((a, b) => b[1] - a[1]).slice(0, 5);
self.postMessage({ words: words.length, characters: event.data.length, top });
});
</script>
<script>
const source = document.querySelector("#worker-code").textContent;
const worker = new Worker(URL.createObjectURL(new Blob([source], { type: "text/javascript" })));
worker.addEventListener("message", (event) => {
const { words, characters, top } = event.data;
const lines = top.map(([word, n]) => `${word}: ${n}`);
document.querySelector("#result").textContent =
`Words: ${words}\nCharacters: ${characters}\n${lines.join("\n")}`;
});
document.querySelector("#count").addEventListener("click", () => {
worker.postMessage(document.querySelector("#text").value);
});
</script>
</body>
</html>The worker only receives a string and returns a plain object, both of which copy easily. All page updates happen on the main thread in the message listener.
Quick Quiz
1. What is the main benefit of moving a heavy calculation into a Web Worker?
A worker runs on another thread; the work takes about as long, but clicks, typing and animations keep working.
2. Which of these can a worker NOT use?
Workers have no DOM; they send results back to the page, which updates the elements.
3. What happens to an ArrayBuffer after worker.postMessage(buffer, [buffer])?
Listing the buffer as a transferable moves ownership without copying.
4. What happens if you try to send a function with postMessage?
The structured clone algorithm cannot copy functions.
Learn JavaScript with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
