How workers work
Learn what workers create, why startup and structured cloning cost work, when transfers move ownership, and where shared workers fit.
- 01Follow one worker messageFollow a tea order from the page to the worker and back. Say what each side has of its own: global names and a queue of jobs.
- 02Choose copy or hand overSay when a message carries a copy of the data, and when a transfer hands the bytes over and leaves the sender's buffer empty.
- 03Use workers wiselyExplain why starting a worker costs work. Reuse workers for jobs that repeat. Pick a shared worker or a worklet only for the job it fits.
A worker is another place to run code
A worker is JavaScript that runs apart from the page. The page has its own event loop: the queue of jobs it works through one at a time, such as clicks and drawing. A worker has a separate one.
So a worker can do big jobs, like working out numbers or reading a large file. Meanwhile the page still answers clicks and keeps drawing. But a worker is not free:
- Starting a worker takes work.
- Sending data to it and back takes work too.
A worker is worth it only when the job is bigger than those costs.
A browser worker has its own global object and its own event loop. The page and the worker do not share normal JavaScript objects. They talk by sending messages. Each message usually carries a copy of the data.
The earlier Web Workers lesson showed how to use a worker. This lesson looks inside. It answers two questions:
- What must the browser do before the worker reads its first message?
- What must the browser do to carry a value from one side to the other?
The last lesson was about agents and realms. A worker is one real browser tool built on those ideas. The next lesson, SharedArrayBuffer & Atomics, shows memory that both sides really can share.
One tiny worker round trip
Start with one message and one reply. Here is the plan:
- The page starts a worker from a file named
worker.js. - The page sends an order for two teas.
- The worker reads the order and turns the item and the amount into text.
- The worker sends that short text back.
const worker = new Worker("worker.js");worker.onmessage = (event) => { console.log(event.data);};worker.postMessage({ item: "tea", qty: 2 });self.onmessage = (event) => { const { item, qty } = event.data; self.postMessage(item + ": " + qty);};In main.js:
- Line 1 starts the worker from
worker.js. - Line 2 sets
onmessage: the function that runs later, when the reply arrives. - Line 5 sends the order with
postMessage.
In worker.js, self is the worker's own global object. It plays the part that window plays on a page. The worker joins the two values into the text tea: 2 and sends it back. The page logs tea: 2.
postMessage. The worker builds the text tea: 2 and sends it back, and the page logs it.This replay runs the lesson's own code one step at a time. It is not a real browser debugger. It shows the order of one message going over and one reply coming back.
script
function handleMessage(order) { return order.item + ": " + order.qty;}const delivered = structuredClone(message);const reply = handleMessage(delivered);console.log(reply);Notice what the worker does not do. It does not reach into the page and change a button or a heading. It only sends data back. The page then decides what to show.
Each worker has its own engine and event loop
An isolate is the engine's name for one separate JavaScript world. It has its own values, its own global names, and its own clean-up of unused memory. Browsers build this in different ways. The simple rule still holds:
- A worker has its own global names.
- A worker has its own queue of jobs.
- A long job in the worker does not block the page's queue.
const pageName = "Asha";const workerName = "Mina";console.log(pageName);console.log(workerName);Line 1 stores a name for the page. Line 2 stores a second name. Lines 3 and 4 print Asha and Mina. No worker starts here; the names only stand for the two sides. A real worker would not see either page variable. It starts with its own self. It has no page document, so it cannot reach the DOM, the page's tree of elements. It cannot read the page's global names either.
You run a shop and hire a helper. The helper sits at their own desk with their own to-do list. They work while you serve customers. They cannot move things on your counter. If something should change there, they send you a note, and you change it.
- In real life: A helper gets their own desk
- In JavaScript: A worker gets its own global names
- In real life: The helper has their own to-do list
- In JavaScript: A worker has its own event loop
- In real life: You hand over a written task
- In JavaScript: A message goes to the worker
- In real life: Only you arrange your shop
- In JavaScript: Only the page changes its DOM
Separate event loops explain a common surprise. postMessage does not run the other side's handler right away. It only puts a job in the other side's queue. The reply is queued in the same way. So treat both as results that arrive later.
This keeps the page quick to respond. But it also means there are no shared variables. So:
- Put every value the worker needs into the message.
- Put the result the worker makes into the reply.
The tea example above does exactly that. The worker never reads a page variable.
Why starting a worker costs work
new Worker("worker.js") looks like one short line. But the browser has a lot to do before the worker is ready:
- Make a new global object for the worker.
- Set up the worker's own event loop.
- Fetch the script file, read it, and run its top lines.
How long this takes is not a fixed number. It depends on the device, the script size, whether the file is already saved in the browser, and how busy the browser is.
const startupSteps = ["global", "event loop", "script"]; console.log(startupSteps.join(", "));// A new worker needs all three before it can read its first message.Line 1 lists the three setup steps. This is only a teaching model: the browser does not keep a real array like this. Line 3 prints global, event loop, script. All three happen before the worker can read the tea order.
So a worker can be a poor choice for a tiny sum. The page pays three times:
- The setup.
- Sending the message.
- Sending the reply back.
Some jobs repeat many times, such as making small preview pictures or building a search index. For those, keep a few workers alive and reuse them. Then you pay the setup once, not once per job.
Do not start a new worker for each small job. When many similar jobs are big enough to need a worker, reuse a small worker pool: a fixed group of workers that take turns.
A pool does not make things faster by itself. It needs a few rules of its own:
- A queue of jobs that are waiting.
- A way to cancel jobs nobody needs any more.
- A plan for when a job fails.
- A limit on the number of workers, so they do not slow down the rest of the browser.
A later lesson, Parallel patterns with workers, covers these rules.
const jobs = ["filter tea", "filter coffee"];const workers = ["worker-1", "worker-2"]; const assignments = jobs.map((job, index) => ({ job, worker: workers[index % workers.length],})); console.log(assignments.map(({ worker }) => worker).join(","));Line by line:
- Line 1 lists two jobs.
- Line 2 lists two workers that are already running.
- Lines 4 to 7 give each job to a worker.
%gives the remainder of a division, so the job numbers 0, 1, 2, 3 go to workers 0, 1, 0, 1. The workers take turns. - Line 9 prints
worker-1,worker-2.
This model only shows who gets which job. A real pool must also wait for a worker's reply before it gives that worker the next job.
Structured clone copies data
Structured clone is the browser's built-in way to copy a value for a message. It visits every part of the value and builds a brand-new copy for the other side. You can call it yourself with structuredClone(value).
A small order is quick to copy. A big array full of nested objects has many more parts to visit, so it takes more work.
const order = { item: "tea", qty: 2 };const copy = structuredClone(order);order.qty = 3;console.log(copy.qty);- Line 1 makes an order with
qty2. - Line 2 makes a separate copy called
copy. - Line 3 changes the original's
qtyto 3. - Line 4 prints
2, because the copy was made before the change and does not follow the original.
order while qty is still 2. Line 3 changes only the original, so copy.qty is still 2.try { structuredClone(() => "tea");} catch (error) { console.log(error.name);}Some values cannot be copied, and trying throws an error named DataCloneError. Inside try, line 2 tries to copy a function. The copy fails, so the catch part runs and line 4 prints the error's name, DataCloneError. Two common cases that fail:
- A function. It is code tied to the place where it was made, and the message format has no way to copy that.
- A DOM element, such as a button. It belongs to the page, and the worker has no page.
Model copy work before you optimize
The size of a message does not tell you the whole cost of copying it. But it is a good first question: how much data goes across, and how often?
- A message with many nested objects has many parts to visit.
- A large message sent on every screen redraw can cost more than the work you moved into the worker.
The playground below has two modes. Copy is the structured clone you just saw. Transfer hands the data over instead of copying it; the next section explains how.
const messageSize = 12;const mode = "copy"; // change to "transfer"const report = modelMessageCost(messageSize, mode); console.log(report.copyWork);console.log(report.senderHasData);12How much copying the browser must do.
still has itThe sender keeps its data only in Copy mode.
12The receiver gets the same amount in both modes.
Copy: the browser copies all 12 units of the message. That is 12 units of copy work. The sender still has its data.
Change the message size, then switch between Copy and Transfer.
- In Copy mode, the sender keeps its data. The copy work grows with the message size.
- In Transfer mode, there is no copy work. The receiver gets the data, and the sender no longer has it.
This is a teaching model. It does not measure real time.
In a real app, first make the message smaller when you can. Send an ID, a short result, or only the part that changed, not the whole app's data. Then measure the real app. The shape of your objects and the browser matter more than any guess.
Transfer hands data over instead of copying
An ArrayBuffer is a block of raw bytes, such as the data of a picture or a sound. A transferable is a value like this that can be handed over instead of copied.
To hand it over, you pass a transfer list: an array of the things to move. The bytes then move to the other side. Nothing is copied. The sender's buffer is left empty. We say it is detached.
const buffer = new ArrayBuffer(8);const received = structuredClone(buffer, { transfer: [buffer] });console.log(buffer.byteLength);console.log(received.byteLength);- Line 1 makes a buffer of eight bytes.
- Line 2 hands the buffer over.
{ transfer: [buffer] }is the transfer list. - Line 3 prints
0. The old buffer is empty now.byteLengthis the number of bytes in a buffer. - Line 4 prints
8. The new buffer,received, has all the bytes.
postMessage(buffer, [buffer]) follows the same rule. The second argument is the transfer list.
received. The old buffer is left empty, so line 3 prints 0 and line 4 prints 8.This replay runs the lesson's real transfer code. A transfer moves the bytes to one new owner. The two sides do not share them.
script
const received = structuredClone(buffer, { transfer: [buffer] });console.log(buffer.byteLength);console.log(received.byteLength);A transfer is not shared memory. It is a handover to one new owner. Use it for a large block of bytes that only the other side needs next. Sometimes both sides must see the same bytes at the same time. That needs SharedArrayBuffer and careful rules about who writes when. The next lesson covers it.
Choose a worker shape for a real app
Picture a shop's search page. It gets a large list of products once. Then it filters the list after every key the user types. Here are three ways to use a worker:
- Start a new worker on every key press. This pays the setup cost again and again.
- Send the whole product list back and forth each time. This pays the copy cost again and again.
- Keep one worker that already has the list, and send it only the short search text. This is usually the best choice.
Next, decide on a message plan (sometimes called a protocol). Write down:
- What each request looks like.
- What reply you expect.
- What happens if the user changes their mind halfway.
- Which side owns any block of bytes.
Give every job an ID number, so an old reply cannot replace a newer search result. Stop or reuse workers when the work is really done.
let latestJobId = 2; function requestSearch(query) { latestJobId += 1; worker.postMessage({ id: latestJobId, query });} worker.onmessage = ({ data }) => { if (data.id !== latestJobId) return; console.log(data.results.join(", "));};- Line 1 remembers the ID of the newest job.
- Lines 3 to 6 give each new search a bigger ID and send it with the search text.
- Line 8 handles each reply that comes back.
- Line 9 stops early if the reply's ID is not the newest one, so an old reply is ignored.
This matters when someone types fast. An older search may finish after a newer one. Its result must not replace the newest result on the page.
{ item: "tea", qty: 2 }- A large array of plain numbers
- An
ArrayBuffernamed in the transfer list - An
ImageBitmap(a picture ready to draw) named in the transfer list () => "tea"- A button element from the page
Sort each value by what happens when you send it to a worker in a message.
The same idea works outside the browser. Node.js has workers too, in a module named worker_threads. The code below starts a Node worker, sends it the tea order, and prints the reply reply tea: 2. The lesson's tests run it in Node to check that.
const { Worker } = require("node:worker_threads");
const worker = new Worker(
`const { parentPort } = require("node:worker_threads");
parentPort.on("message", ({ item, qty }) => parentPort.postMessage(item + ": " + qty));`,
{ eval: true },
);
worker.once("message", (reply) => {
console.log("reply", reply);
worker.terminate();
});
worker.postMessage({ item: "tea", qty: 2 });- Lines 3 to 7 start a worker. Its code is written right in the text, and
{ eval: true }tells Node to run that text as the worker's script. - Inside the worker,
parentPortplays the part ofself: it gets the order and poststea: 2back. - Lines 9 to 12 wait for one reply, print it, and stop the worker.
- Line 13 sends the order.
Common worker misconceptions
- “A worker can touch the page DOM.” It cannot. The worker sends a result, and the page changes its own elements.
- “Messages share my object.” A normal message carries a copy. If you change the original later, the copy does not change.
- “A transfer is just a faster copy.” A transfer hands the bytes over. The sender's buffer is left empty.
- “More workers always means faster.” Each worker costs setup, messages, and memory. Too many workers also fight over the same computer, and the gain can disappear.
- “A worker pool is only about speed.” Its queue and its rules for cancelling jobs decide which results the user sees. Get them wrong, and the page shows the wrong answer.
The table below puts similar-sounding ideas side by side. Read each row as: the term, what it really means, and what it is easy to mix it up with.
| Which idea? | What does it mean? | What is it not? |
|---|---|---|
| A worker | A separate place to run JavaScript. It has its own global names and its own event loop. | It is not a second copy of the page. It has no DOM. |
| Structured clone | The browser makes a full, separate copy of the data in a message. | It is not a live link to the original. Later changes to the original do not reach the copy. |
| A transfer | The bytes are handed over to the other side. The sender's buffer is left empty. | It is not just a faster copy. The sender cannot use those bytes any more. |
| A worker pool | A small group of workers that stay alive and take turns with jobs. | It is not a new worker for every button click. |
Three questions clear up most mix-ups. For any value, ask:
- Where does this value live right now?
- Was it copied, or handed over?
- Which event loop will handle the next message?
Practice exercises
What does this program print?
const cart = { item: "tea" };
const copy = structuredClone(cart);
cart.item = "coffee";
console.log(copy.item);It prints tea. structuredClone makes a separate object. Changing cart.item later does not change copy.item.
After the transfer, what does line 3 print for the old buffer's length?
const buffer = new ArrayBuffer(4);
structuredClone(buffer, { transfer: [buffer] });
console.log(buffer.byteLength);It prints 0. The four bytes were handed over, so the old buffer has none left.
A photo editor has many similar picture jobs. Instead of starting a new worker on every click, what should it reuse?
Use a small worker pool: a few workers that stay alive and take turns. The setup is paid once, not once per job, and the app has one place to keep its queue of jobs.
Which part of the page can a normal worker not use?
The DOM. A worker cannot use the page's document. Only the page can change the elements you see.
Your worker makes a large ArrayBuffer result. The worker does not need it any more. Should it copy the buffer or transfer it?
Transfer the ArrayBuffer. The page gets the bytes without a copy, and the worker's buffer is left empty, so nobody uses the old one by mistake.
Which kind of worker can serve several tabs from the same site?
A SharedWorker. Several tabs from the same site each connect to it through their own port.
Check your understanding
For each question, first ask which idea it tests:
- separate event loops,
- copied values,
- handed-over bytes,
- or a kind of worker.
Question 1 of 7What does a dedicated worker get of its own?
Choose an answer to see the explanation.
Question 2 of 7What does this copy example print?
Read the code, then predictconst order = { qty: 2 }; const copy = structuredClone(order); order.qty = 3; console.log(copy.qty);Choose an answer to see the explanation.
Question 3 of 7What does a transfer do to the sender's
ArrayBuffer?Choose an answer to see the explanation.
Question 4 of 7Why not start a new worker for each small job?
Choose an answer to see the explanation.
Question 5 of 7What does this code print when it tries to copy a function?
Read the code, then predicttry { structuredClone(() => "tea"); } catch (error) { console.log(error.name); }Choose an answer to see the explanation.
Question 6 of 7When is a shared worker a good fit?
Choose an answer to see the explanation.
Question 7 of 7What is a worklet?
Choose an answer to see the explanation.
Key takeaways
- A dedicated worker has its own global names and its own event loop. It cannot use the page's DOM.
- Starting a worker takes setup work. Reuse workers when the same big job repeats.
- Structured clone makes a separate copy of a message. Bigger, more nested data takes more work to copy.
- A transfer hands an
ArrayBufferover. The sender's buffer is left empty, with abyteLengthof0. - A shared worker serves several tabs from the same site through ports. A worklet runs inside one special browser job, such as sound or drawing.
Remember the one-liner.
A worker gives you a separate event loop, but starting it and every message you send cost work.
Coming next: SharedArrayBuffer & Atomics. There, two sides really share the same memory, so they must take care not to write at the same time.