Web Workers
Learn how Web Workers keep browser interfaces responsive by running CPU-heavy JavaScript on another thread, passing structured-clone messages, and using transferables safely.
- 01Move heavy work off the UICreate a worker, post work to it, receive results, handle errors, and terminate it when it is no longer needed.
- 02Send the right dataExplain structured clone, why functions and DOM nodes fail, and when transferables avoid copying.
- 03Choose workers deliberatelyCompare classic workers, module workers, worker-safe APIs, and tasks that should stay on the main thread.
The UI needs its waiter
A browser page has a main thread: the thread that runs most of your JavaScript, responds to input, updates the DOM, calculates layout, and helps the browser paint. If you keep that thread busy with a long calculation, the page feels stuck.
A Web Worker is a separate JavaScript environment that can run code on another thread. A dedicated worker belongs to the page that created it. The page and the worker do not share variables or the DOM; they send messages with postMessage.
Imagine a small restaurant with one waiter. If the waiter leaves the dining room to chop onions for a minute, every table waits. Hiring a kitchen helper fixes that: the waiter still talks to customers while the helper does heavy prep.
- In real life: The only waiter keeps serving tables
- In JavaScript: The main thread keeps the UI responsive
- In real life: The helper chops vegetables in the back
- In JavaScript: A worker runs CPU-heavy JavaScript away from the UI
- In real life: The helper never talks to customers
- In JavaScript: Workers cannot touch the DOM or call
document.querySelector - In real life: Tickets pass through the kitchen window
- In JavaScript: Messages pass through
postMessage
Where the analogy stops: A real helper can look into the dining room. A worker cannot reach the page's DOM at all; it must ask the main thread to update the interface.
This lesson covers dedicated workers, message passing, structured clone, transferable objects, and module workers. If the words “task” and “event loop” feel fuzzy, keep the Event loop lesson nearby.
Creating and stopping a dedicated worker
FOUNDATIONA dedicated worker is created with new Worker(url). In a production app, your bundler often gives you that URL. In this lesson, each playground creates a safe predefined Blob URL so the code runs on this page without fetching external files.
// main.jsconst worker = new Worker(workerUrl); worker.postMessage({ type: "count", limit: 120000 }); worker.onmessage = (event) => { renderResult(event.data);}; worker.onerror = (event) => { console.error(event.message);}; // worker.jsself.onmessage = (event) => { const result = doHeavyWork(event.data.limit); self.postMessage(result);};The worker global object is self. Dedicated workers have postMessage, onmessage, timers such as setTimeout, fetch, IndexedDB, and more. They do not have document, page window, layout, or direct access to React state.
A worker can keep running after the button that started it is no longer useful. Call worker.terminate() when the work is stale, when the user resets the experiment, or when a component unmounts. terminate() stops the worker immediately.
Jank lab: move prime counting off the UI
INTERACTIVE“Jank” is the feeling of a page missing frames. Counting primes is a friendly CPU task because it uses only JavaScript and no network. Run it on the main thread first: the moving indicator freezes because the waiter is chopping onions. Then run the same calculation in a worker.
function countPrimesUpTo(limit) { let count = 0; for (let value = 2; value <= limit; value += 1) { if (isPrime(value)) count += 1; } return count;} // Main thread: blocks clicks, animation, and paint until done.const total = countPrimesUpTo(120000); // Worker thread: the same CPU work runs away from the UI.worker.postMessage({ type: "count", limit: 120000 });- Run the main-thread version, then the worker version. Watch the moving bar.
The main-thread button deliberately blocks JavaScript. The worker button creates a Blob URL worker, posts the same task, and terminates it after the reply. Durations vary by machine.
The worker version still uses CPU. It is not magic. The win is that the main thread can keep handling input and paint while the helper thread works. Do not create workers for every small loop; startup and message costs are real.
Messages and structured clone
INTERACTIVEThe page and worker communicate by message events. You call postMessage(value); the other side later receives an event whose data is the message. The value is copied with the structured clone algorithm. It understands more than JSON: Date, Map, Set, ArrayBuffer, nested arrays, and many plain objects.
The kitchen helper does not grab the waiter’s notebook. The waiter tears off a ticket and passes that copy through the window. If the waiter changes the notebook after that, the kitchen still cooks from the old ticket.
- In real life: The waiter writes an order ticket
- In JavaScript: The main thread builds a message object
- In real life: The helper receives a copy of the ticket
- In JavaScript: The worker receives structured-cloned data
- In real life: Changing the waiter's notebook later does not edit the ticket
- In JavaScript: Mutating the original after
postMessagedoes not mutate the copy
Where the analogy stops: A paper ticket can contain a drawing of anything. Structured clone has rules: functions, symbols, and DOM nodes are not cloneable.
const original = { created: new Date("2026-09-25T08:30:00.000Z"), labels: new Map([["level", "warm-up"]]), nested: ["ticket", { count: 1 }],}; worker.postMessage(original);original.nested[1].count = 99; // The worker receives a structured-clone copy with count still 1.worker.postMessage(() => "functions cannot be cloned");- Post a rich object and compare the worker's copy with the changed original.
Structured clone preserves many built-in data types, but it sends a copy. Later mutations to the original object do not change the worker's message.
DataCloneError is the browser refusing to clone a function.JSON turns everything into text and loses types such as Date and Map. Structured clone keeps many browser and JavaScript data types, but it still cannot copy behavior such as functions. A bad message throws DataCloneError before it reaches the worker.
Transferables: hand over the pot
INTERACTIVECopying a huge binary buffer can be wasteful. Some objects are transferable: ownership moves to the receiver instead of copying bytes. Transferables include ArrayBuffer, MessagePort, ImageBitmap, and OffscreenCanvas. After you transfer an ArrayBuffer, the sender’s buffer is detached and its byteLength becomes 0.
If the kitchen needs a recipe, a photocopy is fine. If it needs the heavy soup pot, making another pot is silly. Hand over the real pot, but remember the waiter cannot keep serving from it afterward.
- In real life: Photocopying a recipe
- In JavaScript: Structured clone copies data
- In real life: Handing over the actual soup pot
- In JavaScript: Transfer moves ownership of a transferable object
- In real life: The waiter no longer has the pot
- In JavaScript: The sender's transferred
ArrayBufferis detached
Where the analogy stops: Transferring is great only when the sender truly no longer needs that object. If both sides need independent data, copy instead.
const buffer = new ArrayBuffer(4 * 1024 * 1024); worker.postMessage({ buffer });console.log(buffer.byteLength); // sender still owns it const fastBuffer = new ArrayBuffer(4 * 1024 * 1024);worker.postMessage({ buffer: fastBuffer }, [fastBuffer]);console.log(fastBuffer.byteLength); // 0: ownership transferred- Compare copying bytes with transferring ownership.
Without a transfer list the sender keeps its buffer. With a transfer list the sender's byteLength becomes 0 because the bytes moved to the worker.
SharedArrayBuffer is the special case for shared memory. It requires cross-origin isolation in browsers, so most lessons and many apps should treat it as an advanced deployment topic rather than a default worker tool.
Classic workers and module workers
INTERACTIVEClassic workers load classic scripts. Inside a classic worker, importScripts("helper.js") can synchronously load more classic scripts. Module workers are created with new Worker(url, { type: "module" }). They use module loading rules, so they can use import and export in browsers that support them.
const classic = new Worker(classicUrl);// classic workers use importScripts("helper.js") when they need scripts. try { const moduleWorker = new Worker(moduleUrl, { type: "module" }); moduleWorker.postMessage("ping");} catch (error) { console.log("module workers are not available here");}- Try a classic worker and a module worker in this browser.
A classic worker loads classic scripts and can use importScripts. A module worker participates in ES module loading and is requested with { type: 'module' }.
The worker kind does not change the big safety rule: neither kind gets DOM access. The choice is mostly about how code is loaded and bundled. In real projects, check your bundler’s worker URL pattern and your site’s Content Security Policy. This site sends no CSP that blocks blob: workers, so these experiments can use Blob URLs.
The message timeline
STEP THROUGHpostMessage is not a function call into the worker. It queues a message. The current script continues, microtasks still follow the usual rules from the Promises and event-loop lessons, and the worker’s onmessage runs later in the worker’s own event loop. A reply comes back as a later message event on the Worker object.
Trace the real message timeline: the main thread posts, the worker handles the message later in its own event loop, and the reply returns as another message.
script
console.log("main: before postMessage");worker.postMessage(ticket);ticket.items.push("pie");console.log("main: changed original"); // later, in the worker event loopself.onmessage = (event) => { console.log(event.data.items.join(", ")); self.postMessage("done");};Where workers fit in real interfaces
PRACTICALWorkers are best for work that is CPU-heavy, independent from the DOM, and easy to describe as input data plus output data: parsing big files, searching many records, generating thumbnails, compressing images, crunching numbers, or sorting a very large table. A normal fetch already avoids blocking the main thread; a worker helps only if the response needs heavy processing afterward.
const names = ["Grace", "Ada", "Linus", "Brendan", "Hedy"]; // Small list: keep it simple.const sortedHere = [...names].sort((a, b) => a.localeCompare(b)); // Large list: send a copy and render the reply.worker.postMessage(names);worker.onmessage = (event) => renderRows(event.data);- Grace
- Ada
- Linus
- Brendan
- Hedy
Sort a small list on the main thread or send it to a worker. For tiny arrays, the worker is educational overhead.
- Update button text after a click
- Parse a 20 MB export file before showing a summary
- Render pixels with
OffscreenCanvaswhen supported - Fetch JSON while the user waits
- Hide API keys from the user
- Sort 100,000 rows for a table
- Call
alert()from background code - Format six dates for a settings page
Sort each job by the best home. Think about DOM access, work size, and whether the browser API is already asynchronous.
| Feature | Main thread | Worker thread |
|---|---|---|
| Can touch DOM | Yes: document, layout, events, paint coordination | No: no document or window DOM |
| Good at | Small UI updates, event handlers, starting async work | CPU-heavy calculations and parsing |
| Communication | Direct calls inside the page | postMessage and message events |
| Stopping | Return from your function | worker.terminate() stops immediately |
Common misconceptions
- “Workers can update the page.” They cannot touch the DOM. Send a message back and let the main thread update UI.
- “Workers make code faster automatically.” They make the UI more responsive. Total wall time may improve, stay similar, or even get worse for small tasks.
- “Messages share objects.” Normal messages copy with structured clone. Later changes to the original object are separate.
- “Transfer is just a faster copy.” Transfer moves ownership. The sender loses that buffer.
- “A worker hides client secrets.” Worker code and messages are still in the user’s browser.
- “Service workers are the same thing.” Dedicated workers help a page compute. Service workers sit between pages and network requests; that is the next lesson.
Practice exercises
5 EXERCISESPredict what the two lines print when an object is cloned and then the original is changed.
const original = { nested: { count: 1 } };
const copy = structuredClone(original);
original.nested.count = 9;
console.log(copy.nested.count);
console.log(original.nested.count);The cloned object keeps count: 1. The original is changed to 9, so the logs are 1 and 9.
Sketch the main-thread and worker responsibilities for sorting table data.
// Goal: keep the UI responsive while processing rows.
worker.postMessage(rows);
worker.onmessage = (event) => renderRows(event.data);// main thread
worker.postMessage(rows.map(({ id, score }) => ({ id, score })));
worker.onmessage = (event) => renderRows(event.data);
// worker
self.onmessage = (event) => {
self.postMessage(event.data.toSorted((a, b) => b.score - a.score));
};Send plain row data to the worker and render the sorted reply on the main thread. Do not send table elements or try to call DOM APIs in the worker.
Predict the byte lengths after an ArrayBuffer transfer.
const buffer = new ArrayBuffer(4);
const copy = structuredClone(buffer, { transfer: [buffer] });
console.log(buffer.byteLength);
console.log(copy.byteLength);Transferring moves the ArrayBuffer to the clone. The original prints 0; the receiving copy prints 4.
Answer the instant check, then fix the code so the page updates correctly.
// worker.js
self.onmessage = () => {
const status = document.querySelector("#status");
status.textContent = "Done";
};// worker.js
self.onmessage = () => {
self.postMessage({ status: "Done" });
};
// main.js
worker.onmessage = (event) => {
document.querySelector("#status").textContent = event.data.status;
};The worker cannot access document. It sends plain data back; the main thread updates the DOM.
Predict which line prints second when a worker reply competes with current script and a microtask.
console.log("main start");
queueMicrotask(() => console.log("main microtask"));
setTimeout(() => console.log("worker reply event"), 0);
console.log("main end");The second line is main end. The full order is main start, main end, main microtask, worker reply event.
Check your understanding
8 QUESTIONSQuestion 1 of 8What problem does a Web Worker solve?
Choose an answer to see the explanation.
Question 2 of 8What does this structured-clone example print?
Read the code, then predictconst original = { nested: { count: 1 } }; const copy = structuredClone(original); original.nested.count = 9; console.log(copy.nested.count); console.log(original.nested.count);Choose an answer to see the explanation.
Question 3 of 8Which value cannot be sent with structured clone?
Choose an answer to see the explanation.
Question 4 of 8What does this ArrayBuffer transfer print?
Read the code, then predictconst buffer = new ArrayBuffer(4); const copy = structuredClone(buffer, { transfer: [buffer] }); console.log(buffer.byteLength); console.log(copy.byteLength);Choose an answer to see the explanation.
Question 5 of 8What happens when you call
worker.terminate()?Choose an answer to see the explanation.
Question 6 of 8Which API is available inside many dedicated workers?
Choose an answer to see the explanation.
Question 7 of 8Which statement about module workers is most accurate?
Choose an answer to see the explanation.
Question 8 of 8What order does this worker-reply model print?
Read the code, then predictconsole.log("main start"); queueMicrotask(() => console.log("main microtask")); setTimeout(() => console.log("worker reply event"), 0); console.log("main end");Choose an answer to see the explanation.
Key takeaways
- Dedicated workers let a page run CPU-heavy JavaScript away from the main UI thread.
- Workers communicate with
postMessage; normal messages are structured-clone copies. - Transferables move ownership, so a transferred
ArrayBufferbecomes detached on the sender side. - Workers have useful APIs such as timers,
fetch, and IndexedDB, but no DOM. - Terminate workers when their work is stale, and listen for
errorevents.
Final definition.
A Web Worker is a browser-managed JavaScript environment that runs separately from the page’s main thread and communicates by message events.
Up next: Service workers & offline apps.