completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

How workers work

Learn what workers create, why startup and structured cloning cost work, when transfers move ownership, and where shared workers fit.

By the end, you can
  • 01
    Follow 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.
  • 02
    Choose 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.
  • 03
    Use 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.

Definition

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.
main.js: the page starts a worker and sends an orderPop out in the code editor (opens in a new tab)JavaScript
const worker = new Worker("worker.js");worker.onmessage = (event) => {  console.log(event.data);};worker.postMessage({ item: "tea", qty: 2 });
worker.js: the worker reads the order and repliesPop out in the code editor (opens in a new tab)JavaScript
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.

Left: the page window runs main.js. Right: the worker window runs worker.js and its onmessage handler. The order { item: "tea", qty: 2 } goes from the page to the worker. The text tea: 2 comes back, and the page logs it.pagemain.jslogs tea: 2{ item: "tea", qty: 2 }tea: 2workerworker.jsonmessage
The page sends the order with postMessage. The worker builds the text tea: 2 and sends it back, and the page logs it.
Step through one worker message
Step 0 of 6Ready
Your turn: follow the blue line

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.

Running in
  1. script
Next: line 1
function handleMessage(order) {  return order.item + ": " + order.qty;}const delivered = structuredClone(message);const reply = handleMessage(delivered);console.log(reply);
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.
Two names in two separate places (a teaching model)Pop out in the code editor (opens in a new tab)JavaScript
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.

Real-life analogyHiring a helper

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.

The three setup steps, as a listPop out in the code editor (opens in a new tab)JavaScript
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.

Starting a worker, from left to right. Step 1, a global. Step 2, an event loop. Step 3, the script. Only then the first message runs.setup, done onceglobalevent loopscriptmessagestep 1step 2step 3then this
Before a new worker reads its first message, the browser gets these three things ready. That setup is work, so a tiny job may not be worth a new worker.

So a worker can be a poor choice for a tiny sum. The page pays three times:

  1. The setup.
  2. Sending the message.
  3. 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.

Practical rule

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.

Two workers take turns with the jobsPop out in the code editor (opens in a new tab)JavaScript
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.

A copy does not change when the original changesPop out in the code editor (opens in a new tab)JavaScript
const order = { item: "tea", qty: 2 };const copy = structuredClone(order);order.qty = 3;console.log(copy.qty);
  • Line 1 makes an order with qty 2.
  • Line 2 makes a separate copy called copy.
  • Line 3 changes the original's qty to 3.
  • Line 4 prints 2, because the copy was made before the change and does not follow the original.
The order cart now holds qty 3. An arrow labelled structuredClone points to the copy cart, which still holds qty 2.qty: 3orderchanged on line 3structuredCloneqty: 2copystill 2
Line 2 copies order while qty is still 2. Line 3 changes only the original, so copy.qty is still 2.
A function cannot be copied, so it throws DataCloneErrorPop out in the code editor (opens in a new tab)JavaScript
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.

Playground: copy or hand over one message
The cost model in codeJavaScript
const messageSize = 12;const mode = "copy"; // change to "transfer"const report = modelMessageCost(messageSize, mode); console.log(report.copyWork);console.log(report.senderHasData);
ResultCopy
copy work12

How much copying the browser must do.

sender's datastill has it

The sender keeps its data only in Copy mode.

receiver gets12

The receiver gets the same amount in both modes.

Try it yourself
Pick a message size and how to send it

Copy: the browser copies all 12 units of the message. That is 12 units of copy work. The sender still has its data.

This is a teaching model. It does not measure real time. Real copy work also depends on how the data is built and on the browser.

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.

Hand over an ArrayBuffer and check both sidesPop out in the code editor (opens in a new tab)JavaScript
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. byteLength is 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.

The buffer box holds a card that says 0 bytes. An arrow labelled transfer goes to the received box, which holds a card that says 8 bytes.0 bytesbufferprints 0transfer8 bytesreceivedprints 8
The transfer moves all 8 bytes into received. The old buffer is left empty, so line 3 prints 0 and line 4 prints 8.
Step through handing a buffer over
Step 0 of 4Ready
Your turn: follow the blue line

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.

Running in
  1. script
Next: line 1
const received = structuredClone(buffer, { transfer: [buffer] });console.log(buffer.byteLength);console.log(received.byteLength);
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

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.

Shared workers and worklets

The worker you have seen so far is a dedicated worker: one page owns it. Browsers have other kinds too.

A SharedWorker can serve several pages or tabs from the same site (the same origin). Each tab talks to it through its own port, a MessagePort. A port is like a private phone line between one tab and the worker.

This helps when related tabs should share one connection or one small helper service. It still talks only by messages. It stays alive only while some tab uses it, so do not expect it to keep running after all the tabs close.

Open a port to a shared workerJavaScript
const worker = new SharedWorker("shared-worker.js");const port = worker.port; port.start();port.postMessage({ type: "open-cart" });
  • Line 1 connects to the shared worker by its script name.
  • Line 2 takes this page's port.
  • Line 4 starts the port, so messages can flow.
  • Line 5 sends a small request.

This code has no Run button. The lesson cannot open a second tab or keep a shared worker alive for you.

A worklet is different again. It is a small script that the browser calls during one special job:

  • An audio worklet helps process sound.
  • A paint worklet helps draw part of the page.

Worklets only do that one job and follow its rules. They do not replace a dedicated worker.

Two more facts about worker kinds:

  • A dedicated worker runs a plain script by default. If the script uses import, start it with new Worker("catalog-worker.js", { type: "module" }).
  • A service worker is another kind. The browser starts it when an event comes in and stops it when it is idle. So it is not a place for long background work.

The table below compares the kinds. Each row is one kind: who uses it, and what it is good for.

Each kind of worker has a different job
Which kind?Who uses it?What is it good for?
A dedicated worker (Worker)One page starts it and talks to it with postMessage.Big background jobs for that page, such as working out numbers or reading a large file.
A shared worker (SharedWorker)Several tabs from the same site connect to it. Each tab has its own port.One shared connection or helper for related tabs.
A workletThe browser calls it during one special job.Processing sound, or drawing part of the page.
The page itselfIt owns the DOM and answers the user.Changing elements, handling clicks, and drawing the page.

Pick the simplest kind that fits the job:

  • One page's background job: a dedicated worker.
  • Several tabs from the same site working together: a shared worker.
  • Sound or drawing work that the browser calls: a worklet.

None of them can touch a page's DOM.

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.

Ignore a reply from an older searchJavaScript
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.

Copy, hand over, or fail?
  • { item: "tea", qty: 2 }
  • A large array of plain numbers
  • An ArrayBuffer named in the transfer list
  • An ImageBitmap (a picture ready to draw) named in the transfer list
  • () => "tea"
  • A button element from the page
Try it yourself
0 of 6 correct

Sort each value by what happens when you send it to a worker in a message.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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.

The tea order with a Node.js worker (Node only)JavaScript
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, parentPort plays the part of self: it gets the order and posts tea: 2 back.
  • 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.

Worker ideas that are easy to mix up
Which idea?What does it mean?What is it not?
A workerA 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 cloneThe 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 transferThe 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 poolA 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

Exercise 1 · Warm-upPredict the copied value

What does this program print?

Starter codePop out in the code editor (opens in a new tab)JavaScript
const cart = { item: "tea" };
const copy = structuredClone(cart);
cart.item = "coffee";
console.log(copy.item);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upPredict the length after a transfer

    After the transfer, what does line 3 print for the old buffer's length?

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    const buffer = new ArrayBuffer(4);
    structuredClone(buffer, { transfer: [buffer] });
    console.log(buffer.byteLength);

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeKeep background work ready

      A photo editor has many similar picture jobs. Instead of starting a new worker on every click, what should it reuse?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeName what a worker cannot reach

        Which part of the page can a normal worker not use?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeSend back a large block of bytes

          Your worker makes a large ArrayBuffer result. The worker does not need it any more. Should it copy the buffer or transfer it?

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeCoordinate several tabs

            Which kind of worker can serve several tabs from the same site?

            Answer, then press Check. Spacing and letter case don’t matter.

              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.
              Worker internals quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does a dedicated worker get of its own?

                Choose an answer to see the explanation.

              2. Question 2 of 7What does this copy example print?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                const order = { qty: 2 };
                const copy = structuredClone(order);
                order.qty = 3;
                console.log(copy.qty);

                Choose an answer to see the explanation.

              3. Question 3 of 7What does a transfer do to the sender's ArrayBuffer?

                Choose an answer to see the explanation.

              4. Question 4 of 7Why not start a new worker for each small job?

                Choose an answer to see the explanation.

              5. Question 5 of 7What does this code print when it tries to copy a function?

                Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
                try {
                  structuredClone(() => "tea");
                } catch (error) {
                  console.log(error.name);
                }

                Choose an answer to see the explanation.

              6. Question 6 of 7When is a shared worker a good fit?

                Choose an answer to see the explanation.

              7. 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 ArrayBuffer over. The sender's buffer is left empty, with a byteLength of 0.
              • 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.

              CompleteFrontend Clear concepts. Working examples.