TL;DR
Learn Web Workers to run JavaScript in background threads. Keep the main thread responsive during CPU-intensive tasks and data processing.
Key concepts
- JavaScript Web Workers
- background threads JS
- multithreading JavaScript
- web worker tutorial
Web Workers
JavaScript is single-threaded. Every calculation, DOM update, and event handler runs on the same thread — the main thread. When that thread is busy crunching numbers or processing a large dataset, your UI freezes. Buttons stop responding. Animations stutter. The browser may warn users the page is unresponsive.
Web Workers solve this by running your script in a separate execution context, in parallel with the main thread. You hand off the heavy work, the main thread stays free, and when the result is ready, the worker sends it back.
What You'll Learn
By the end of this lesson you will build the message protocol a worker runs on — a pure reducer that folds { type, payload } messages into a state, the plan that generates those messages, and the fold that runs them — and you will have a real index.html running a prime-search worker in your own browser, with the page staying responsive while it works. You arrive able to reason about the event loop and asynchronous work (08-async-and-promises) and to write classes (18-classes-and-inheritance); you leave able to move CPU-heavy work off the main thread entirely, which is the one thing the event loop cannot do for you.
This is the final lesson of the optional extension tail — the Task Manager capstone is complete without it. Treat it as the answer to a problem the rest of the track sets up but never solves: what to do when the work is not waiting, but computing.
How this lesson runs
This lesson has code in two kinds of places, and the difference is not cosmetic:
- Worker code is browser reference. The
Workerconstructor and the worker-sideselfglobal only exist in a browser — the in-page runner is a headless Node sandbox without them. So every snippet that constructs a worker is plain reference code — read it, then run it in a real browser (save it into an HTML file's<script>and open it) to see it work. - Pure-logic snippets run right here. Where the code is only about JavaScript values — no worker involved — it lives in a runnable
playgroundblock you can execute on the page. In this lesson that is the blocking-computation demo below, which shows exactly what a worker is meant to move off the main thread, and the small message-channel stand-in that follows it.
What Is a Web Worker?
A Web Worker is a JavaScript script that runs in its own execution context, in parallel with and completely separate from the page's main thread. Workers have no access to the DOM, window, or document — they operate in their own isolated scope. Communication between the main thread and a worker happens entirely through message passing.
Normally you'd create a worker by pointing it at an external file:
const worker = new Worker("my-worker.js");
In a browser playground without a file system, you can construct the same thing using a Blob and URL.createObjectURL — a standard pattern for inline workers:
// Build a worker from an inline string
const workerCode = `
self.onmessage = function(event) {
const name = event.data;
self.postMessage("Hello, " + name + "! (from worker)");
};
`;
const blob = new Blob([workerCode], { type: "application/javascript" });
const worker = new Worker(URL.createObjectURL(blob));
worker.onmessage = function(event) {
console.log(event.data);
};
worker.postMessage("Alice");
worker.postMessage("Bob");
self inside the worker refers to the worker's global scope — the same role window plays on the main thread, but not the same object. A worker's global is a WorkerGlobalScope, a different interface with no DOM and a much smaller API surface. self is also the name that works in both places, while window only exists on the main thread. The worker listens on self.onmessage, receives data via event.data, and responds with self.postMessage.
Sending and Receiving Messages
Communication is asynchronous. Both sides use postMessage to send and onmessage to receive. The data you pass is automatically structured-cloned — copied deeply, not shared. This means there are no race conditions by default.
const workerCode = `
self.onmessage = function(event) {
const { type, payload } = event.data;
if (type === "square") {
self.postMessage({ type: "result", value: payload * payload });
}
if (type === "cube") {
self.postMessage({ type: "result", value: payload * payload * payload });
}
};
`;
const blob = new Blob([workerCode], { type: "application/javascript" });
const worker = new Worker(URL.createObjectURL(blob));
worker.onmessage = function(event) {
const { type, value } = event.data;
console.log(`${type}: ${value}`);
};
worker.postMessage({ type: "square", payload: 7 });
worker.postMessage({ type: "cube", payload: 4 });
Using a type field in messages is a common convention. It lets a single worker handle multiple kinds of requests without separate workers for each operation.
There is a catch in what you are allowed to send. Structured clone copies data, not behavior — so a message carrying a function cannot cross the boundary. This snippet runs right here because it uses only structuredClone, the very algorithm postMessage applies (no Worker needed to see the rule):
Debug
This builds a message to hand to a worker and clones it exactly the way postMessage would. It's meant to log the cloned task, but it throws instead. Predict the error, then fix it so the task clones and logs — keeping the same intent (tell the worker to take the square root of each input).
const task = {
id: 1,
input: [4, 9, 16],
transform: (n) => Math.sqrt(n),
};
// postMessage structured-clones the data before the worker receives it.
// structuredClone applies the exact same algorithm, so we can test it here.
const cloned = structuredClone(task);
console.log("Cloned task:", cloned);Expected output:
Cloned task: { id: 1, input: [ 4, 9, 16 ], op: 'sqrt' }The order of those three main-thread statements is not free. A worker delivers a message to whatever handler is registered at the moment the message is dispatched, so the handshake has one workable sequence: construct the worker, register onmessage, then postMessage. Register after dispatching and you have handed over work whose answer has nowhere to arrive.
Worker does not exist in this page's Node sandbox, so the block below is built on the small stand-in class shown here, which delivers to onmessage the same way — the point being that the ordering constraint is real and observable, not a style rule. Running the lines in the declared order prints got: 42; moving the post above the registration prints dropped: 21 instead. Both outputs were produced by running them.
// The stand-in the block below builds on. It doubles whatever you post,
// exactly as the square/cube worker above answers each message it receives.
class Channel {
constructor() { this.onmessage = null; }
post(data) {
if (this.onmessage) this.onmessage({ data: data * 2 });
else console.log("dropped:", data);
}
}
const channel = new Channel();
channel.onmessage = function (event) { console.log("got:", event.data); };
channel.post(21);
Arrange the code
Reassemble the three main-thread lines of a worker handshake, using a Channel stand-in that behaves the way a real Worker does: it delivers each posted message to whatever onmessage handler is registered at the moment of the post, and reports a dropped message when there is none. Only one arrangement prints got: 42 — one of the others runs just as cleanly but loses the message instead. Why does the order matter here?
channel.onmessage = function (event) { console.log("got:", event.data); };const channel = new Channel();channel.post(21);
Why Workers Matter: Blocking vs Non-Blocking
Here is what happens when you run a slow computation on the main thread:
function slowSum(limit) {
let total = 0;
for (let i = 0; i < limit; i++) {
total += i;
}
return total;
}
console.log("Before computation");
// This blocks the thread — nothing else can run during this time
const result = slowSum(500_000_000);
console.log("Result:", result);
console.log("After computation");
// Any UI updates or event listeners were frozen until this finished
Run that and notice the delay before you see the output. On a real page, the browser would be unresponsive for that entire duration.
A reasonable objection at this point: the event loop is supposed to stop long work from blocking the page, and setTimeout(..., 0) is supposed to run "as soon as possible". Commit to an answer for the following before you read on — it is the whole reason workers exist.
Predict
A timer is scheduled with a 0 ms delay BEFORE a long synchronous loop starts. This runs on this page, on the same single main thread a browser gives you. Predict the exact order of the three lines before running it.
console.log("1: start");
setTimeout(() => console.log("2: timer fired"), 0);
let total = 0;
for (let i = 0; i < 200_000_000; i++) {
total += i;
}
console.log("3: loop done, total is", total);The timer is not slow — it is waiting in line behind work that never yields. That is precisely the gap the event loop cannot close: it interleaves tasks that pause, and a CPU-bound loop never pauses. Getting the main thread back means the loop has to stop hogging it — either by being broken into small pieces that yield between them, or by being moved off the thread entirely. A worker does the second, and does it without rewriting the loop.
Here is the same computation handed off to a worker — browser reference code, since the in-page runner has no Worker:
const workerCode = `
self.onmessage = function(event) {
const limit = event.data;
let total = 0;
for (let i = 0; i < limit; i++) {
total += i;
}
self.postMessage(total);
};
`;
const blob = new Blob([workerCode], { type: "application/javascript" });
const worker = new Worker(URL.createObjectURL(blob));
console.log("Starting computation in worker...");
console.log("Main thread is still free to do other things here.");
worker.onmessage = function(event) {
console.log("Worker result:", event.data);
worker.terminate(); // clean up when done
};
worker.onerror = function(error) {
console.error("Worker error:", error.message);
};
worker.postMessage(500_000_000);
Run this in a browser and the main thread logs immediately and stays responsive: the worker does its work in parallel and posts the result back when finished. Notice the error handler — always handle worker errors in production code.
Worker Lifecycle
Workers keep running until you explicitly terminate them or close them from the inside:
| Method | Called from | Effect |
|---|---|---|
worker.terminate() | Main thread | Immediately kills the worker |
self.close() | Inside worker | Worker shuts itself down |
Spinning up a worker is not expensive on its own — from new Worker() to the first message back measures about 2 ms in Chrome for a small, already-cached script, roughly an eighth of a frame. That is cheap once and wasteful a thousand times, which is why the rule still stands: for repeated tasks, reuse a single long-lived worker rather than creating a new one per operation. Every worker is a genuinely separate execution context with its own memory and its own copy of anything it imports, so you pay for each one you keep alive. And 2 ms is the best case: a large worker script, or one the browser has not cached, has to be fetched and parsed on the way up, which costs considerably more.
What Workers Cannot Do
Workers are powerful but deliberately sandboxed:
- No DOM access — cannot read or modify
document,window, or any HTML elements - No
localStorage— synchronous storage APIs are blocked - No
alert/confirm— no UI dialogs - Can use:
fetch,WebSockets,IndexedDB,setTimeout,setInterval,console, and most Web APIs that do not require a rendering context
Transfer
You move a long CSV parse into a worker so the page stays responsive. Requirement: as the worker parses, a progress bar in the page (a DOM element) should fill from 0% to 100%. Inside the worker's onmessage, which approach is the ONLY one that can actually update that bar?
Try It Yourself
Build a worker that finds all prime numbers up to a given limit and reports them back to the main thread. This is browser code — the main-thread half is the handshake you just reassembled, now with a real Worker:
const workerCode = `
function isPrime(n) {
if (n < 2) return false;
for (let i = 2; i <= Math.sqrt(n); i++) {
if (n % i === 0) return false;
}
return true;
}
self.onmessage = function(event) {
const limit = event.data;
const primes = [];
for (let i = 2; i <= limit; i++) {
if (isPrime(i)) primes.push(i);
}
self.postMessage({ count: primes.length, primes: primes.slice(0, 10) });
};
`;
const blob = new Blob([workerCode], { type: "application/javascript" });
const worker = new Worker(URL.createObjectURL(blob));
worker.onmessage = function(event) {
const { count, primes } = event.data;
console.log(`Found ${count} primes`);
console.log("First 10:", primes.join(", "));
worker.terminate();
};
worker.onerror = function(err) {
console.error("Worker error:", err.message);
};
console.log("Searching for primes up to 100,000...");
worker.postMessage(100_000);
Run it for real: the scaffold
That snippet needs a page around it. Save the whole file below as index.html, then open it in your browser — double-clicking the file is enough; no server is required, because the worker is built from a Blob rather than fetched as a separate file.
What you should see. The heading appears with the status line Searching for primes up to 100,000…, and the counter beside it keeps ticking upward the entire time — that is the proof the main thread never froze. A moment later the status is replaced by Found 9592 primes, with the first ten listed underneath. Click Block the main thread for the contrast: the counter visibly stalls, then jumps — skipping the values it would have shown — and the page ignores clicks until it comes back. On a 2024 laptop the button's loop measured 2.06-2.14 s in Chrome across six clicks, so expect a pause of roughly two seconds; a faster or slower machine will differ, and the number itself matters less than the fact that the counter stops while the worker version never did. Same computer, same kind of arithmetic; the only difference is which thread it ran on.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Prime Worker</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 34rem; margin: 2rem auto; padding: 0 1rem; }
#status { font-weight: 600; }
#ticker { font-variant-numeric: tabular-nums; color: #555; }
button { cursor: pointer; margin-top: 1rem; }
</style>
</head>
<body>
<h1>Prime Worker</h1>
<p id="status">Starting…</p>
<p>Main thread is alive: <span id="ticker">0</span></p>
<p id="result"></p>
<button id="block">Block the main thread</button>
<script>
const statusEl = document.querySelector("#status");
const tickerEl = document.querySelector("#ticker");
const resultEl = document.querySelector("#result");
// A counter driven by the main thread. If it keeps moving, the main
// thread is free; if it stalls, something is blocking it.
let ticks = 0;
setInterval(() => {
ticks += 1;
tickerEl.textContent = ticks;
}, 100);
// The worker's own source, as a string, turned into a loadable URL.
const workerCode = `
function isPrime(n) {
if (n < 2) return false;
for (let i = 2; i <= Math.sqrt(n); i++) {
if (n % i === 0) return false;
}
return true;
}
self.onmessage = function (event) {
const limit = event.data;
const primes = [];
for (let i = 2; i <= limit; i++) {
if (isPrime(i)) primes.push(i);
}
self.postMessage({ count: primes.length, primes: primes.slice(0, 10) });
};
`;
const blob = new Blob([workerCode], { type: "application/javascript" });
const worker = new Worker(URL.createObjectURL(blob));
// Register the handler BEFORE posting — the handshake order from the block above.
worker.onmessage = function (event) {
const { count, primes } = event.data;
statusEl.textContent = `Found ${count} primes`;
resultEl.textContent = "First 10: " + primes.join(", ");
worker.terminate();
};
worker.onerror = function (err) {
statusEl.textContent = "Worker error: " + err.message;
};
statusEl.textContent = "Searching for primes up to 100,000…";
worker.postMessage(100_000);
// The contrast: the same kind of work, on the main thread this time.
document.querySelector("#block").addEventListener("click", () => {
let total = 0;
for (let i = 0; i < 3_000_000_000; i++) {
total += i;
}
resultEl.textContent = "Blocking loop finished, total " + total;
});
</script>
</body>
</html>
In the browser, try increasing the worker's limit to 500_000 and notice the ticker keeps counting while the worker crunches through the numbers. Then move the prime loop itself into the click handler and watch the ticker freeze for the whole search — the same code, the same result, and a page that stops responding.
Build a Worker Message Protocol
The worker code above cannot run in this headless sandbox — there is no Worker, no self. But the protocol those workers speak can. Every worker in this lesson receives { type, payload } messages in its self.onmessage and answers with postMessage (the square/cube dispatcher under "Sending and Receiving Messages" is exactly that shape). The message-handling logic is just JavaScript over plain values — no thread required — so you can build and test it right here, then drop it into a real worker unchanged.
This is a build task, and a fitting last one for the track: it pulls together message shapes, pure functions, array work, and folding a sequence into a result — the plumbing every worker sits on top of. Three functions are stubbed with only their signatures; the checks below them fail until each returns the right value. Run it as-is to see the first failure — TODO 1 — implement that, then work down.
The spec lives in the prompt. handleMessage is a pure reducer: it takes the current state and one { type, payload } message and returns a new state, never touching the old one — the same discipline that makes structured-cloned messages safe, since a worker's state must not depend on shared references. chunkPlan turns a list of items into the messages you would post to feed them in. runPlan folds a whole message sequence through handleMessage to get the final state — exactly what a worker accumulates as messages arrive.
Build
Finish the build. Three functions are stubbed with only their signatures; the checks below them fail until each returns the right value. Spec: handleMessage(state, msg) is a PURE reducer over { type, payload } messages — it returns a NEW state and must never mutate the state passed in. For an 'add' message it returns state with payload.n added to total and payload.n appended to a copy of log; for a 'reset' message it returns { total: 0, log: [] }; for any other type it returns the same values in a fresh object. chunkPlan(items, chunkSize) returns an array of { type: 'add', payload: { n } } messages, one per item, in order, walking the items chunkSize at a time. runPlan(messages) folds every message through handleMessage starting from { total: 0, log: [] } and returns the final state. Checks run top to bottom, so the first failure is TODO 1 — implement it first, then work down.
const assert = require('assert');
// TODO 1: handleMessage(state, msg) -> a NEW state, never mutating the one passed in.
// { type: 'add', payload: { n } } -> total + n, and n appended to a copy of log
// { type: 'reset' } -> { total: 0, log: [] }
// any other type -> the same values in a fresh object
function handleMessage(state, msg) {
// your code here
}
// TODO 2: chunkPlan(items, chunkSize) -> one { type: 'add', payload: { n } } per item,
// in order, walking the items chunkSize at a time.
function chunkPlan(items, chunkSize) {
// your code here
}
// TODO 3: runPlan(messages) -> fold every message through handleMessage,
// starting from { total: 0, log: [] }, and return the final state.
function runPlan(messages) {
// your code here
}
// --- Build checks: these must all pass. Do not edit below this line. ---
const start = { total: 0, log: [] };
const next = handleMessage(start, { type: "add", payload: { n: 5 } });
assert.deepStrictEqual(
next,
{ total: 5, log: [5] },
"handleMessage('add') should add payload.n to total and append it to the log",
);
assert.deepStrictEqual(
start,
{ total: 0, log: [] },
"handleMessage must be pure — the state passed in cannot be mutated",
);
assert.deepStrictEqual(
chunkPlan([10, 20, 30], 2),
[
{ type: "add", payload: { n: 10 } },
{ type: "add", payload: { n: 20 } },
{ type: "add", payload: { n: 30 } },
],
"chunkPlan should emit one add message per item, in order",
);
assert.deepStrictEqual(
runPlan(chunkPlan([10, 20, 30], 2)),
{ total: 60, log: [10, 20, 30] },
"runPlan should fold the plan into a final state of total 60",
);
console.log("All checks passed.");
console.log("After add:", handleMessage(start, { type: "add", payload: { n: 5 } }));
console.log("Plan length:", chunkPlan([10, 20, 30], 2).length);
console.log("Final state:", runPlan(chunkPlan([10, 20, 30], 2)));Expected output:
All checks passed.
After add: { total: 5, log: [ 5 ] }
Plan length: 3
Final state: { total: 60, log: [ 10, 20, 30 ] }Once it passes, try two variations and predict each before running:
- An unknown message type. Call
handleMessage({ total: 42, log: [1, 2] }, { type: "square", payload: { n: 9 } }). Predict what comes back before running. You get{ total: 42, log: [ 1, 2 ] }— the same values, because an unrecognized type falls through to the default branch. But it is a fresh object with a copied log, not the one you passed in: the reducer stays pure even when it changes nothing, so the caller's state is never handed back to be mutated later. - A finer chunk size. Call
chunkPlan([10, 20, 30], 1)and compare its length tochunkPlan([10, 20, 30], 3). Predict both before running. Both are3:chunkSizecontrols how the items are walked, not how many messages you get — there is still exactly oneaddmessage per item regardless, so the plan feeds the worker the same three values either way.
Recall
A closing distinction, reaching back across the track. Back in 08-async-and-promises you learned the event loop keeps async work (timers, fetches) from blocking the single main thread. Yet this lesson opened by saying a heavy CPU computation still freezes the UI — and you reach for a Web Worker. If the event loop already prevents blocking, why doesn't it save you here?
Key Takeaways
- Web Workers run JavaScript in a separate execution context, in parallel with the main thread
- Workers cannot access the DOM,
window, ordocument— they live in an isolated scope - Communication is message-based:
postMessageto send,onmessageto receive - Data passed between threads is deep-cloned (structured clone algorithm), not shared
- Use
worker.terminate()orself.close()to clean up workers when done - Always attach an error handler to catch and handle worker exceptions
- Reuse long-lived workers rather than creating new ones per operation
- Inline workers can be created with
BlobandURL.createObjectURLwhen no file system is available
Pro Tip: For communication-heavy workloads, look into
SharedArrayBufferandAtomics— they let workers share memory directly rather than copying data on every message. Pair them withTransferableobjects likeArrayBuffer(passed with the second argument topostMessage) to move large binary data between threads at near-zero cost instead of cloning it. The difference is easy to underrate: a 16 MB buffer measures about 4.3 ms round-tripped as a clone and about 0.6 ms transferred, and cloning gets steadily worse with size while transferring barely moves.
Course Complete!
Congratulations — this is the last lesson of the JavaScript track, the end of the optional extension tail. You now have a solid foundation in modern JavaScript, from the core language and the browser through to the advanced features covered in these final lessons.
What to do next:
- Build a project combining what you've learned — a single-page app or a Node.js tool is a great start
- Explore the JavaScript Playground to experiment further
- Check out MDN Web Docs for reference
Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.