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 pageCreate a worker, send it work, get the result back, handle errors, and stop the worker when you no longer need it.
- 02Send the right dataExplain how messages are copied (structured clone), why functions and DOM elements can't be sent, and when a transfer saves a copy.
- 03Choose workers on purposeCompare classic and module workers, list what a worker can use, and spot jobs that should stay on the main thread.
A second thread for heavy work
A thread is a line of work that the computer runs one step at a time. Every browser page has one main line of work, called the main thread. It does most of the page's jobs:
- It runs most of your JavaScript.
- It reacts to clicks and key presses.
- It changes the DOM, the tree of elements that make up the page.
- It works out the layout and paints the screen.
The main thread does one job at a time. If your code runs a long calculation there, every other job waits. Clicks do nothing. Animations stop. The page feels stuck.
A Web Worker is a second place to run JavaScript, on another thread. While the worker calculates, the main thread stays free for clicks and painting. This lesson uses dedicated workers. A dedicated worker belongs to the one page that made it.
The page and the worker don't share variables, and the worker can't touch the DOM. They talk by sending messages to each other with postMessage.
A small restaurant has one waiter. If the waiter goes to the kitchen to chop onions for ten minutes, every table waits. So the owner hires a kitchen helper. Now the helper chops the onions, and the waiter keeps serving the tables.
- In real life: The only waiter serves the tables
- In JavaScript: The main thread handles clicks and the screen
- In real life: The helper chops vegetables in the back
- In JavaScript: A worker runs long calculations away from the page
- In real life: The helper never talks to customers
- In JavaScript: A worker can't touch the DOM, so it can't call
document.querySelector - In real life: Tickets pass through the kitchen window
- In JavaScript: Messages pass with
postMessage
In this lesson you will learn:
- how to create a worker, send it work, and stop it;
- how messages are copied on the way (this is called structured clone);
- how to hand data over without copying it (transferable objects);
- the two kinds of worker: classic workers and module workers.
The lesson also uses the event loop: the way the browser picks the next job to run, one at a time. If that idea is new, keep the Event loop lesson open beside this one.
Creating and stopping a dedicated worker
FOUNDATIONYou make a dedicated worker with new Worker(url). The url is the address of the worker's own JavaScript file. In a real app, your bundler (the build tool that packs your files) often makes that address for you.
The playgrounds on this page can't load extra files. So each one puts its worker code into a Blob URL. A Blob URL is a short-lived address that the browser makes for text kept in memory.
The code below shows both sides. The top part runs on the page. The bottom part runs inside the worker.
// 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);};Line by line:
- Line 2 creates the worker.
- Line 4 sends it a message: an object that asks it to count up to 120000.
- Lines 6 to 8 run when the worker replies. The reply is in
event.data. - Lines 10 to 12 run if the worker's code throws an error.
- Lines 15 to 18 are the worker's side.
selfis the worker's own global object, likewindowon a page. When a message arrives, the worker does the work and sends the result back withself.postMessage.
postMessage, and the worker sends the result back. The main thread stays free for clicks, but only it can touch the DOM.The next code puts the page's side in order. Line 1 creates the worker. Lines 2 and 3 set up the reply and error handlers first. Line 4 sends the work. Line 7 stops the worker later, once its work is no longer needed.
const worker = new Worker(workerUrl);worker.onmessage = (event) => console.log(event.data);worker.onerror = (event) => console.error(event.message);worker.postMessage({ type: "count", limit: 10 }); // Later, when this work is stale:worker.terminate();A dedicated worker has many of the tools a page has. It has:
postMessageandonmessage, for messages;- timers such as
setTimeout; fetch, for network requests;- IndexedDB, the browser's built-in database.
It does not have document, the page's window, the layout, or your React state.
Two events on the Worker object tell you something went wrong:
errorfires when the worker's code throws, or when its script fails to load.messageerrorfires when a message arrives but can't be turned back into a value.
One more problem shows up sooner. If postMessage can't copy a value, it throws an error right away, on the side that sent it.
A worker keeps running until something stops it, even after its result is no longer useful. Call worker.terminate() when the work is out of date, when the user resets an experiment, or when a component leaves the page. terminate() stops the worker at once.
The page calls terminate(). Inside the worker, self.close() lets the worker stop itself after it finishes its current message. Neither one sends a last reply for you. If the page needs to know the worker stopped, send your own "done" message.
Jank lab: count primes without freezing the page
INTERACTIVEJank means the page stutters or freezes because the screen wasn't redrawn in time. To show it, this lab counts prime numbers: numbers that divide evenly only by 1 and by themselves. Counting them is pure JavaScript work. It needs no network, only the computer's processor, the CPU.
Try it:
- Click Run on main thread. The moving dot stops until the count ends.
- Click Run in worker. The same count runs in a worker, and the dot keeps moving.
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 dot.
The main-thread button blocks the page on purpose. The worker button makes a worker from a Blob URL, sends it the same job, and stops it after the reply. Times depend on your computer.
The worker still uses the CPU. The work doesn't disappear. The gain is that the main thread stays free for clicks and painting.
Workers also cost something. Starting a worker takes time, and so does sending each message. So don't make a worker for every small loop.
Messages and structured clone
INTERACTIVETo send a message, one side calls postMessage(value). A moment later, the other side gets a message event. The value is in the event's data property, event.data.
The value itself doesn't travel. The browser makes a copy of it, using a fixed list of steps called structured clone. Structured clone can copy more kinds of values than JSON can:
- dates (
Date); MapandSet;ArrayBuffer, a block of raw bytes;- arrays and plain objects, even nested ones.
You can run the same copy steps yourself with structuredClone(value). The small example below uses it to show what a worker would get.
const order = { item: "tea" };const copy = structuredClone(order);order.item = "coffee";console.log(copy.item);console.log(order.item);Line by line:
- Line 1 makes an
orderobject withitem: "tea". - Line 2 makes a separate copy,
copy. - Line 3 changes the original to
"coffee". - Line 4 prints
tea, because the copy didn't change. Line 5 printscoffee.
A worker works the same way. It gets the value as it was when you posted it. Later changes to the original don't reach it.
order while it still says tea. Line 3 changes only the original, so copy.item is still "tea".The waiter writes the order in a notebook, tears off a ticket, and passes it through the kitchen window. The helper cooks from that ticket. If the waiter changes the notebook afterward, the ticket in the kitchen stays the same.
- In real life: The waiter writes an order ticket
- In JavaScript: The page builds a message object
- In real life: The helper gets a copy of the ticket
- In JavaScript: The worker gets a structured-clone copy
- In real life: Changing the notebook later doesn't change the ticket
- In JavaScript: Changing the original after
postMessagedoesn't change the copy
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");- Send an object to a worker. Then compare the worker's copy with the changed original.
Structured clone keeps types like Date and Map, but it sends a copy. Changing the original afterward doesn't change the worker's copy.
JSON turns everything into plain text. A Date becomes a string, and a Map becomes an empty object. Structured clone keeps those types as they are.
But structured clone can't copy functions or DOM elements. If you try, postMessage throws a DataCloneError on the sending side, and nothing reaches the worker.
Transferables: hand data over instead of copying it
INTERACTIVECopying is fine for small values. For a big block of bytes, copying takes time and uses twice the memory. Some objects are transferable. The browser moves them to the receiver instead of copying them, and the sender gives them up. These are transferable:
ArrayBuffer, a block of raw bytes;MessagePort, one end of a private message channel;ImageBitmap, an image that is ready to draw;OffscreenCanvas, a canvas that can draw away from the page.
After you transfer an ArrayBuffer, the sender's buffer is detached: it is empty. Its byteLength, the number of bytes it holds, becomes 0.
const buffer = new ArrayBuffer(4);const copy = structuredClone(buffer, { transfer: [buffer] });console.log(buffer.byteLength);console.log(copy.byteLength);Line by line:
- Line 1 makes a buffer that holds 4 bytes.
- Line 2 passes it to
structuredClonewith the option{ transfer: [buffer] }. That option says: move this buffer, don't copy it. With a worker, you write the same thing asworker.postMessage(buffer, [buffer]). - Line 3 prints
0. The sender's buffer is empty now. - Line 4 prints
4. The receiver has the bytes.
So never transfer a buffer that the page still needs to read.
copy. The sender's buffer is left empty, so its byteLength is 0.If the kitchen needs a recipe, a photocopy is fine. If it needs the big soup pot, nobody makes a second pot. The waiter hands over the real pot. After that, the waiter can't serve soup from it.
- In real life: Photocopying a recipe
- In JavaScript: Structured clone copies the data
- In real life: Handing over the real soup pot
- In JavaScript: A transfer moves a transferable object
- In real life: The waiter no longer has the pot
- In JavaScript: The sender's transferred
ArrayBufferis empty
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 the bytes with moving them.
Without a transfer list, the sender keeps its buffer. With a transfer list, the bytes move to the worker, so the sender's byteLength becomes 0.
There is one way to truly share memory: SharedArrayBuffer. Both sides read and write the same bytes. Browsers allow it only on pages that are cross-origin isolated. That means the server sends two special response headers:
Cross-Origin-Opener-Policy: same-originCross-Origin-Embedder-Policy: require-corp
Check the global crossOriginIsolated before you use SharedArrayBuffer. This page's code editor runs in a locked-down frame on purpose, so it doesn't have it. Shared memory is an advanced topic. Copying and transferring cover most needs.
Classic workers and module workers
INTERACTIVEThere are two kinds of worker. They differ only in how they load code:
- A classic worker is made with
new Worker(url). It loads a normal script. To load more scripts, it callsimportScripts("helper.js"). That loads and runs each script at once, before the next line runs. - A module worker is made with
new Worker(url, { type: "module" }). It loads a JavaScript module, so it can useimportandexportin 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 making a classic worker and a module worker in this browser.
A classic worker loads a normal script and can use importScripts. A module worker loads a module, can use import and export, and is made with { type: 'module' }.
Both kinds follow the same safety rule: neither one can touch the DOM. The choice is only about how your code is loaded and bundled.
In a real project, check two things:
- how your bundler makes worker URLs;
- your site's Content Security Policy (CSP): rules that say where the page may load scripts from. Some CSPs block
blob:workers.
This site's CSP doesn't block blob: workers, so the labs can use Blob URLs. After a lab stops a worker, it also revokes (cancels) the worker's Blob URL. Then the browser can let go of the script text.
importScripts() works only in classic workers. A module worker uses import lines at the top of the file instead. Mixing the two is a common bug when a project switches from classic to module workers.
The message timeline
STEP THROUGHpostMessage doesn't call the worker right away. It puts the message in a queue, a waiting line, and returns. Then things happen in this order:
- The rest of the current script keeps running.
- Microtasks, such as promise callbacks, run right after the script, as usual (see the Promises lesson).
- The worker's
onmessageruns later, in the worker's own event loop. - The worker's reply comes back even later, as a new
messageevent on theWorkerobject.
postMessage only queues a copy of the ticket, and the main script keeps going. The worker's onmessage runs later and sees salad and soup, without pie.Step through the code below and watch when each line runs.
The main thread posts and keeps going. The worker handles the message later, on its own thread. The reply comes back 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 apps
PRACTICALWorkers are best for jobs that:
- take a lot of CPU time;
- don't need the DOM;
- take data in and give data back.
Some examples: reading a big file, searching many records, making small preview images, compressing images, crunching numbers, or sorting a very large table.
A normal fetch already waits without blocking the main thread. A worker helps only if the response needs heavy work afterward.
A SharedWorker is a different kind of worker. Several pages from the same site (the same origin) can connect to one shared worker. Each page talks to it through its own MessagePort, the page's end of a message channel.
- Inside the shared worker, a
connectevent fires for each page that connects. - Each port must be started with
start(), or given anonmessagehandler, before messages flow.
Use a shared worker to coordinate several pages, not as a faster dedicated worker.
const shared = new SharedWorker("shared-worker.js");shared.port.onmessage = (event) => console.log(event.data);shared.port.start();shared.port.postMessage({ type: "lookup", key: "tea" }); // shared-worker.jsself.onconnect = (event) => { const port = event.ports[0]; port.onmessage = () => port.postMessage("ready");};This example can't run here. It needs a separate shared-worker.js file on a real site. The dedicated Blob workers above are the ones that run on this page.
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 a list this small, a worker is only for practice.
- Change a button's text after a click
- Read a 20 MB export file before showing a summary
- Draw pixels with
OffscreenCanvaswhere the browser supports it - 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
Put each job where it belongs. Ask three things: does it need the DOM? Is it big work? Does the browser already do it without blocking?
The table below compares the two threads. Read each row as a question, then the answer for each side.
| What you want to know | On the main thread | In a worker |
|---|---|---|
| Can it change the page? | Yes. It has document, the layout, events, and painting. | No. It has no document and no page window. |
| What is it good at? | Small page updates, event handlers, and starting async work. | Long calculations and reading big data. |
| How do you talk to it? | You call its functions directly. | You send messages with postMessage and listen for message events. |
| How does it stop? | Your function returns. | worker.terminate() stops it at once. |
Common misconceptions
- "Workers can update the page." They can't touch the DOM. The worker sends a message back, and the main thread updates the page.
- "Workers make code faster." They keep the page free to respond. The total time may get shorter, stay the same, or even get longer for small jobs.
- "Messages share objects." Normal messages are copies. Later changes to the original don't reach the copy.
- "A transfer is just a faster copy." A transfer moves the object. The sender no longer has it.
- "A worker can hide secrets." Worker code and its messages still run in the user's browser, where the user can read them.
- "Service workers are the same thing." A dedicated worker helps one page calculate. A service worker sits between your pages and the network. That is the next lesson.
Practice exercises
5 EXERCISESAn object is copied, and then the original is changed. Predict what the two console.log lines print.
const original = { nested: { count: 1 } };
const copy = structuredClone(original);
original.nested.count = 9;
console.log(copy.nested.count);
console.log(original.nested.count);The copy still has count: 1. Line 3 changes only the original to 9. So the lines print 1, then 9.
A page needs to sort table rows by score. Write which part runs on the main thread and which part runs in the worker.
// Goal: keep the page free to respond while sorting 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));
};The page sends plain row data: just the id and score of each row. The worker sorts it by score, highest first, and sends it back. The page draws the sorted rows. Don't send table elements, and don't call DOM methods in the worker.
Predict the two byte lengths after an ArrayBuffer is transferred.
const buffer = new ArrayBuffer(4);
const copy = structuredClone(buffer, { transfer: [buffer] });
console.log(buffer.byteLength);
console.log(copy.byteLength);The transfer moves the ArrayBuffer into copy. The original prints 0, and copy prints 4.
Answer the quick check. Then fix the code so the page shows "Done".
// 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;
};A worker has no document. So the worker sends plain data back, and the main thread changes the page.
In this code, setTimeout stands in for a worker's reply. Predict which line prints second.
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 can't 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 of these can a dedicated worker use?
Choose an answer to see the explanation.
Question 7 of 8Which sentence about module workers is true?
Choose an answer to see the explanation.
Question 8 of 8In what order does this code print? (
setTimeoutstands in for the worker's reply.)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
- A dedicated worker lets a page run long calculations away from the main thread, so the page stays free to respond.
- The page and the worker talk with
postMessage. A normal message is a structured-clone copy. - A transfer moves an object instead of copying it. A transferred
ArrayBufferis left empty on the sender's side. - Workers have timers,
fetch, and IndexedDB, but no DOM. - Stop workers with
terminate()when their work is out of date, and listen forerrorevents.
Final definition.
A Web Worker runs JavaScript on its own thread, apart from the page's main thread. The page and the worker talk only by sending messages.
Up next: Service workers and offline apps.