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.
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 single blocking-computation demo below, which shows exactly what a worker is meant to move off the main thread.
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' }
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.
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 — copy it into an HTML file's <script> and open it in a browser to run it:
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);
In the browser, try increasing the limit to 500_000 and notice the main thread remains unblocked while the worker crunches through the numbers.
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.