As you saw in the event loop lesson, a web page's JavaScript runs on a single main thread, shared with clicks, scrolling and rendering. Any long calculation, such as resizing a photo, parsing a large file, searching thousands of records or running a physics simulation, blocks that thread and freezes the page.
Web Workers solve this by running JavaScript on a separate background thread. The main page and the worker can't touch each other's variables. Instead, they talk by sending messages back and forth with postMessage, a bit like two people passing notes. While the worker crunches numbers, the page stays smooth and responsive.
In this lesson you'll create workers, send data in both directions, handle errors, and learn what workers can and can't do. You'll also meet a few related browser APIs that keep apps responsive and well-behaved: AbortController for cancelling work such as fetch requests, BroadcastChannel for talking between open tabs, and service workers, which power offline support and installable web apps.
Creating a worker and sending messages
// main.js
const worker = new Worker("worker.js");
worker.postMessage({ numbers: [1, 2, 3] });
worker.addEventListener("message", (e) => console.log("Result:", e.data));
// worker.js
self.addEventListener("message", (e) => {
const sum = e.data.numbers.reduce((a, b) => a + b, 0);
self.postMessage(sum);
});new Worker(url)starts a script on a background thread. Add{ type: "module" }to useimportinside it.postMessage(data)sends data; the other side receives it asevent.datain amessagelistener.- Inside the worker,
selfrefers to the worker's global scope. worker.terminate()stops a worker immediately; inside the worker,self.close()does the same.
What workers can and can't do
| Workers can | Workers can't |
|---|---|
| Run any calculation | Access the DOM: no document, no window |
Use fetch, timers, IndexedDB, crypto | Read the page's variables directly |
| Import other scripts and modules | Use localStorage |
Draw on an OffscreenCanvas | Show alerts |
Data sent with postMessage is copied using the same structured clone algorithm as structuredClone, so objects, arrays, Maps and Dates work, but functions and DOM elements don't. For large binary data such as an ArrayBuffer, pass it in a transfer list to move it instantly instead of copying: worker.postMessage(buffer, [buffer]).
Wrapping a worker in a promise
Messages are events, but most code is easier to write with promises. A small wrapper turns a worker request into something you can await:
function runInWorker(worker, data) {
return new Promise((resolve, reject) => {
worker.addEventListener("message", (e) => resolve(e.data), { once: true });
worker.addEventListener("error", reject, { once: true });
worker.postMessage(data);
});
}
const result = await runInWorker(worker, { numbers });Libraries such as Comlink build on this idea so a worker's functions can be called almost like normal async functions.
JavaScript Web Workers Syntax
Workers and friends
const worker = new Worker("worker.js", { type: "module" });
worker.postMessage(data);
worker.onmessage = (e) => show(e.data);
worker.terminate();
const controller = new AbortController();
fetch(url, { signal: controller.signal });
controller.abort();
const channel = new BroadcastChannel("app");
channel.postMessage({ theme: "dark" });JavaScript Web Workers Summary
- Web Workers run JavaScript on a background thread so heavy work doesn't freeze the page.
- The page and worker communicate with
postMessageandmessageevents; data is copied. - Workers can't access the DOM,
windoworlocalStorage. - Wrapping worker messages in a promise lets you use
await. AbortControllercancels work,BroadcastChannellinks tabs, and service workers enable offline apps.
JavaScript Web Workers Examples
Each example runs in the page. Edit the code and press Run to see what changes.
A worker built from a string
Press the buttons and try typing while the work runs. With the worker, typing stays smooth.
<input placeholder="Type here while it works" style="width: 100%; padding: 6px">
<button id="main">Count primes on main thread</button>
<button id="bg">Count primes in a worker</button>
<p id="out"></p>const out = document.querySelector("#out");
const LIMIT = 600_000;
function countPrimes(limit) {
let count = 0;
for (let n = 2; n < limit; n++) {
let prime = true;
for (let d = 2; d * d <= n; d++) if (n % d === 0) { prime = false; break; }
if (prime) count++;
}
return count;
}
const workerCode = `
self.onmessage = (e) => {
let count = 0;
for (let n = 2; n < e.data; n++) {
let prime = true;
for (let d = 2; d * d <= n; d++) if (n % d === 0) { prime = false; break; }
if (prime) count++;
}
self.postMessage(count);
};`;
const url = URL.createObjectURL(new Blob([workerCode], { type: "text/javascript" }));
document.querySelector("#main").onclick = () => {
const t = performance.now();
out.textContent = `Main thread: ${countPrimes(LIMIT)} primes in ${Math.round(performance.now() - t)}ms`;
};
document.querySelector("#bg").onclick = () => {
const t = performance.now();
out.textContent = "Working in the background...";
try {
const worker = new Worker(url);
worker.onmessage = (e) => {
out.textContent = `Worker: ${e.data} primes in ${Math.round(performance.now() - t)}ms`;
worker.terminate();
};
worker.postMessage(LIMIT);
} catch (err) {
out.textContent = "Workers aren't allowed in this preview: " + err.message;
}
};Line-by-line explanation
- Lines 4 to 12A deliberately slow calculation on the main thread.
- Lines 14 to 23The same calculation as the worker's code. Normally this lives in its own file; a string keeps the example in one script.
- Line 24Turn the string into a file in memory that a worker can load.
- Lines 26 to 29On the main thread, the page freezes until the count finishes.
- Line 35Start a background thread.
- Lines 36 to 39When the worker replies, show the result and stop the worker.
- Line 40Send the job to the worker; the page stays responsive meanwhile.
Cancelling work with AbortController
function slowTask(ms, signal) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => resolve(`finished after ${ms}ms`), ms);
signal.addEventListener("abort", () => {
clearTimeout(timer);
reject(new DOMException("Task cancelled", "AbortError"));
}, { once: true });
});
}
const controller = new AbortController();
slowTask(1000, controller.signal)
.then(console.log)
.catch((err) => console.log(err.name, "-", err.message));
setTimeout(() => controller.abort(), 200);
const quick = new AbortController();
slowTask(100, quick.signal).then(console.log);Line-by-line explanation
- Lines 1 to 9Any async task can accept a signal and stop when it fires
abort, just likefetchdoes. - Lines 11 to 14Start a one-second task.
- Line 16Cancel it after 200ms; the promise rejects with an AbortError.
- Lines 18 to 19A task that isn't cancelled finishes normally.
Talking between tabs with BroadcastChannel
Open this page in two tabs to see messages arrive in the other one.
<button id="send">Say hello to other tabs</button>
<ul id="log"></ul>const log = document.querySelector("#log");
const add = (text) => log.append(Object.assign(document.createElement("li"), { textContent: text }));
let channel;
try {
channel = new BroadcastChannel("codevra-demo");
channel.onmessage = (e) => add(`Received: ${e.data.text} at ${e.data.time}`);
} catch (err) {
add("BroadcastChannel isn't available in this preview.");
}
document.querySelector("#send").addEventListener("click", () => {
const msg = { text: "Hello from another tab", time: new Date().toLocaleTimeString() };
channel?.postMessage(msg);
add(`Sent: ${msg.text}`);
});Line-by-line explanation
- Line 6Every tab that opens a channel with the same name can talk to the others.
- Line 7Messages from other tabs arrive here, not in the tab that sent them.
- Line 15Send an object; it's copied with the structured clone algorithm.
Common JavaScript Web Workers Mistakes
Using the DOM inside a worker
// worker.js
document.querySelector("#out").textContent = result; // ReferenceError: document is not defined// worker.js
self.postMessage(result);
// main.js
worker.onmessage = (e) => (out.textContent = e.data);Sending functions or elements
worker.postMessage({ callback: () => {}, el: document.body }); // DataCloneErrorworker.postMessage({ action: "resize", width: 800 });A worker for tiny jobs
const w = new Worker("add.js");
w.postMessage([2, 3]); // the setup costs more than adding two numbersconst sum = 2 + 3;JavaScript Web Workers in Real Projects
Workers in production
Figma, Google Sheets and photo editors run heavy calculations in workers. VS Code on the web runs language features in workers so typing never lags. Progressive web apps like Twitter Lite and Starbucks use service workers for offline support and fast repeat visits. Search boxes cancel outdated requests with AbortController, and banking sites use BroadcastChannel to log every tab out together.
JavaScript Web Workers Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
Send data to the worker.
const worker = new Worker("worker.js");
worker.({ task: "sort", items: [3, 1, 2] });
postMessage sends a copy of the data.What happens to the original array after this runs?
const list = [1, 2, 3];
worker.postMessage(list);
list.push(4);
// inside the worker: console.log(e.data.length)What is the main reason to use a Web Worker?
Which of these can a worker use?
fetchWithTimeout(promiseFactory, ms) should call promiseFactory(signal) with an AbortController signal, and abort it if it takes longer than ms. It should resolve with the result when fast enough. Fix it.
function fetchWithTimeout(promiseFactory, ms) {
const controller = new AbortController();
setTimeout(() => controller.abort, ms);
return promiseFactory();
}
const slow = (signal) => new Promise((resolve, reject) => {
const t = setTimeout(() => resolve("data"), 200);
signal?.addEventListener("abort", () => { clearTimeout(t); reject(new Error("aborted")); });
});
fetchWithTimeout(slow, 50).then(console.log, (e) => console.log(e.message));- Slow tasks are aborted
- Fast tasks resolve
JavaScript Web Workers Quiz
5 questions to check what you learned.
How do the page and a worker communicate?
- By sending messages with postMessage
- By sharing variables
- Through the DOM
- Through CSS
How is data passed with postMessage?
- By reference, so both sides share it
- As a JSON string only
- It's copied with the structured clone algorithm (or transferred, for buffers)
- It can't pass objects
What does AbortController do?
- Stops the whole page
- Closes a worker
- Provides a signal that can cancel fetch requests and other async work
- Catches errors
What do service workers mainly enable?
- Offline caching, push notifications and installable web apps
- Faster CSS
- Access to the DOM from workers
- Multiple CPUs for the main thread
Why pass an ArrayBuffer in a transfer list?
- To encrypt it
- Because buffers can't be copied
- To share it so both sides can edit it
- To move it to the worker instantly instead of copying it
JavaScript Web Workers Challenge
Sum of squares in the background
Write sumOfSquares(numbers) that returns a Promise:
- Build a worker from a Blob whose code listens for a message and replies with the sum of the squares of the numbers it receives
- Send
numbersto the worker, resolve with its reply, thenterminate()the worker - If creating the worker throws (some previews block workers), calculate the result on the main thread instead and resolve with that
function sumOfSquares(numbers) {
}
sumOfSquares([1, 2, 3]).then(console.log);- Returns a promise
- Calculates the sum of squares
- Handles an empty list
- Uses a worker and postMessage