Web Workers
Reviewed & published by Brayan K
A Web Worker is a script that runs on a separate background thread, communicating with the main thread only by passing messages, so heavy computation can run without freezing the user interface.
Part of the free JavaScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
They are the answer to the spinning beachball: when a calculation would lock up the page for seconds, you move it to a worker and the UI keeps scrolling, animating, and responding.
What You'll Learn in This Lesson
- Why the main thread freezes
- Create a worker with new Worker(url)
- Send data with postMessage
- Receive replies with onmessage
- How structured clone copies data
- Terminate a worker and when to use one
📚 Prerequisites: Helpful to understand the event loop and callbacks, since worker messaging is event-driven.
💡 About the runnable demos: The real Worker API needs a browser, so it lives in the read-only code blocks below. Each Try it Yourself instead runs a plain-JavaScript model of the same idea (a tiny message bus and a CPU task) so you can see the exact request → compute → reply flow in your console.
👨🍳 Real-World Analogy: The main thread is a waiter taking orders. If the waiter also has to cook a three-hour roast, every other customer waits and the restaurant grinds to a halt. A Web Worker is the kitchen: the waiter hands an order through the window (postMessage), keeps serving everyone else, and picks up the finished plate when the kitchen rings the bell (onmessage).
1️⃣ The Problem & Creating a Worker
JavaScript runs your code on a single main thread. A long synchronous loop blocks everything — clicks, animations, even scrolling. A Web Worker runs a separate script file on its own thread. You create one with new Worker(url), send it work with postMessage, and the main thread is free the entire time.
The real two-file setup looks like this:
⚠️ Different file, different world: A worker has no access to window, document, or the DOM. It is for computation only — send results back and let the main thread update the page.
The demo below models the same flow with a tiny in-process message bus, so you can watch a request go out and a computed reply come back:
// A tiny stand-in for a Worker: you postMessage to it,
// it computes on a "background" tick, then calls your onmessage.
function makeWorker(compute) {
const api = { onmessage: null };
api.postMessage = (data) => {
// setTimeout(0) mimics handing work to another thread
setTimeout(() => {
const result = compute(data);
if (api.onmessage) api.onmessage({ data: result });
}, 0);
};
return api;
}
// "worker.js": sum 0..upTo (the heavy CPU task)
const worker = makeWorker(({ upTo }) => {
let total = 0;
for (let i = 0; i < upTo; i++) total += i;
return total;
});
worker.onmessage = (event) => {
console.log("worker replied:", event.data);
// ✅ Expected output: worker replied: 499500
};
console.log("main thread stays free...");
// ✅ Expected output: main thread stays free...
worker.postMessage({ cmd: "sum", upTo: 1000 });2️⃣ Structured Clone — Data Is Copied, Not Shared
Messages are not shared by reference — they are deep-copied with the structured clone algorithm. The worker gets its own copy, so mutating it on one side never affects the other. Structured clone handles objects, arrays, Map, Set, Date, and typed arrays — but not functions or DOM nodes.
Node ships the same algorithm as the global structuredClone(), so you can prove the copy-not-share behaviour right here:
const original = { rows: [1, 2, 3], meta: new Map([["k", "v"]]) };
// This is exactly what postMessage does to your data
const copy = structuredClone(original);
// Mutating the copy never touches the original
copy.rows.push(99);
console.log("original rows:", original.rows);
// ✅ Expected output: original rows: [ 1, 2, 3 ]
console.log("copy rows:", copy.rows);
// ✅ Expected output: copy rows: [ 1, 2, 3, 99 ]
// Maps survive the clone
console.log("cloned map value:", copy.meta.get("k"));
// ✅ Expected output: cloned map value: v
// Functions do NOT survive — this is why postMessage rejects them
try {
structuredClone({ run: () => {} });
} catch (e) {
console.log("cannot clone:", e.name);
// ✅ Expected output: cannot clone: DataCloneError
}🎯 Your Turn #1 — Send a Message
Replace the blank with the method that sends data into the worker model.
function makeWorker(compute) {
const api = { onmessage: null };
api.postMessage = (data) => setTimeout(() => api.onmessage({ data: compute(data) }), 0);
return api;
}
const doubler = makeWorker((n) => n * 2);
doubler.onmessage = (e) => console.log("got:", e.data);
// 👉 replace ___ with the method that sends a message to the worker
doubler.___(21); // ✅ Expected output: got: 423️⃣ Terminating & When to Reach for a Worker
A worker keeps running until you stop it. Call worker.terminate() from the main thread (or self.close() inside the worker) to shut it down and free its thread. Use a worker for sustained CPU-bound work — parsing big files, image filters, cryptography. Skip it for quick tasks or I/O-bound waiting, where the messaging and copying overhead is not worth it.
The model below adds a job queue and a "terminate" flag so you can see a worker refuse work after shutdown:
function makeWorker(compute) {
let alive = true;
const api = {
onmessage: null,
postMessage(data) {
// Once terminated, the worker can no longer accept work
if (!alive) {
console.log("ignored — worker is terminated");
return;
}
api.onmessage && api.onmessage({ data: compute(data) });
},
terminate() { alive = false; },
};
return api;
}
const sq = makeWorker((n) => n * n);
sq.onmessage = (e) => console.log("result:", e.data);
sq.postMessage(5); // ✅ Expected output: result: 25
// Shut it down, then try to use it again
sq.terminate();
sq.postMessage(9); // ✅ Expected output: ignored — worker is terminated🎯 Your Turn #2 — Read the Reply
The reply arrives as an event. Replace the blank with the property that holds the payload.
function makeWorker(compute) {
const api = { onmessage: null };
api.postMessage = (d) => setTimeout(() => api.onmessage({ data: compute(d) }), 0);
return api;
}
const upper = makeWorker((s) => s.toUpperCase());
upper.onmessage = (event) => {
// 👉 replace ___ with the property that carries the result
console.log(event.___); // ✅ Expected output: HELLO
};
upper.postMessage("hello");🧠 Quick Recall
Predict the answer before revealing it.
No — workers have no DOM access. Send the result back and let the main thread update the page.
Throws a DataCloneError — functions cannot be structured-cloned.
The prime sieve — it is CPU-bound. The fetch already runs off-thread; async/await handles it.
Common Errors & Fixes
- 1️⃣ Trying to touch the DOM inside a worker
- 2️⃣ Posting something unclonable
- 3️⃣ Forgetting to terminate
- 4️⃣ Using a worker for tiny tasks
🎯 Mini Challenge — Request/Reply With IDs
Real apps send many jobs and need to match each reply to its request. Add an id to each message and route the reply back through a pending-jobs map — the foundation of a worker RPC layer.
function makeWorker(compute) {
const api = { onmessage: null };
api.postMessage = (msg) =>
setTimeout(() => api.onmessage({ data: { id: msg.id, result: compute(msg.payload) } }), 0);
return api;
}
const worker = makeWorker((n) => n * n);
const pending = new Map();
let nextId = 1;
// Promise-based wrapper: send a job, resolve when its reply arrives
function run(payload) {
return new Promise((resolve) => {
const id = nextId++;
pending.set(id, resolve);
worker.postMessage({ id, payload });
});
}
worker.onmessage = ({ data }) => {
const resolve = pending.get(data.id);
pending.delete(data.id);
resolve(data.result);
};
async function main() {
const results = await Promise.all([run(2), run(3), run(4)]);
console.log("squares:", results);
// ✅ Expected output: squares: [ 4, 9, 16 ]
}
main();Quick Reference — Web Workers
| Goal | Code |
|---|---|
| Create | new Worker("worker.js") |
| Send | worker.postMessage(data) |
| Receive (main) | worker.onmessage = (e) => ... |
| Inside worker | self.onmessage / self.postMessage |
| Stop | worker.terminate() |
📚 Keep Learning
- • MDN — Using Web Workers
- • MDN — Worker
- • MDN — Structured clone algorithm
🎉 Lesson Complete — Web Workers!
- ✅ Workers run heavy CPU work on a separate thread so the UI stays responsive
- ✅ Communicate only via postMessage / onmessage
- ✅ Data is structured-cloned — copied, never shared; no functions or DOM nodes
- ✅ terminate() frees the thread; use workers for sustained CPU work
Practice quiz
What is a Web Worker?
- A faster version of setTimeout
- A CSS animation tool
- A script that runs on a separate background thread
- A type of network request
Answer: A script that runs on a separate background thread. A Web Worker is a script that runs on a separate background thread, so heavy computation does not freeze the UI.
Which kind of work is a Web Worker best suited for?
- Sustained CPU-bound work like parsing big files or image filters
- I/O-bound waiting like awaiting a fetch
- Updating the DOM
- Reading cookies
Answer: Sustained CPU-bound work like parsing big files or image filters. Workers shine for sustained CPU-bound work; async/await already handles I/O-bound waiting off the main thread.
How do the main thread and a worker communicate?
- By sharing variables directly
- Through the DOM
- Via localStorage only
- By passing messages with postMessage and onmessage
Answer: By passing messages with postMessage and onmessage. They never share variables; they pass messages — postMessage to send, onmessage to receive.
How do you create a worker in the real browser API?
- createWorker(url)
- new Worker(url)
- Worker.start(url)
- spawn(url)
Answer: new Worker(url). You create a worker with new Worker(url), pointing at a separate script file.
When you postMessage an object, how is it transferred?
- Deep-copied using the structured clone algorithm
- Shared by reference
- Converted to a string only
- Not transferred at all
Answer: Deep-copied using the structured clone algorithm. Data is deep-copied with the structured clone algorithm, so each side gets its own independent copy.
What happens if you try to postMessage a function?
- It works fine
- It silently becomes null
- It throws a DataCloneError because functions can't be cloned
- It runs the function immediately
Answer: It throws a DataCloneError because functions can't be cloned. Functions (and DOM nodes) cannot be structured-cloned, so posting them throws a DataCloneError.
Can a Web Worker access the DOM (e.g., document.getElementById)?
- Yes, fully
- No — workers have no access to window, document, or the DOM
- Only read access
- Only in Chrome
Answer: No — workers have no access to window, document, or the DOM. A worker has no access to window, document, or the DOM; it is for computation only.
On the main thread, how do you receive a reply from the worker?
- worker.onreply
- worker.receive
- worker.result
- worker.onmessage
Answer: worker.onmessage. The main thread reads replies via worker.onmessage, with the payload on event.data.
How do you shut a worker down and free its thread?
- worker.stop()
- worker.terminate()
- worker.kill()
- delete worker
Answer: worker.terminate(). Call worker.terminate() from the main thread (or self.close() inside the worker) to shut it down.
After a worker is terminated, what happens to further work sent to it?
- It runs anyway
- It restarts the worker
- It is ignored because the worker is shut down
- It throws a network error
Answer: It is ignored because the worker is shut down. Once terminated, the worker no longer accepts work — new jobs are ignored.
Continue this course
- Previous: AbortController & Cancelling Async Work
- Next: IntersectionObserver (Lazy Load & Infinite Scroll) — React to element visibility without slow scroll listeners
- Quick reference: JavaScript cheat sheet
Frequently asked questions
Why do I need a Web Worker if JavaScript already has async/await?
async/await only helps with work that waits (network, timers) — it frees the thread while something else happens. It does nothing for work that computes: a heavy loop, image processing, or parsing a huge file still blocks the single main thread and freezes the UI. A Web Worker runs that CPU-bound code on a separate OS thread, so the page stays responsive while the number-crunching happens elsewhere.
How do the main thread and a worker communicate?
They never share variables; they pass messages. You call worker.postMessage(data) to send, and the worker receives it via its onmessage handler. The worker replies with its own postMessage, which the main thread receives on worker.onmessage. It is a two-way mailbox — no shared memory, no race conditions on ordinary data.
What is structured clone and why does it matter for workers?
When you postMessage an object, it is not shared by reference — it is deep-copied using the structured clone algorithm. That copy can include objects, arrays, Maps, Sets, Dates, and typed arrays, but NOT functions, DOM nodes, or class methods. So you send plain data, and both sides get their own independent copy, which is exactly why workers are safe from data races.
When should I NOT use a Web Worker?
Skip a worker for anything quick or anything that mostly waits on I/O — the messaging overhead and the cost of copying data can outweigh the benefit. Workers shine for sustained CPU-bound work: parsing megabytes, image/video filters, cryptography, physics, or large computations. Also remember a worker cannot touch the DOM, so it is for computation, not rendering.