cf.completefrontendCode editorOpen lab
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
    Trace a worker boundaryExplain the separate globals, event loop, message delivery, and reply in one small worker round trip.
  • 02
    Choose copy or transferPredict when structured cloning duplicates data and when a transferable moves ownership away from the sender.
  • 03
    Spend concurrency carefullyRecognize startup cost, reuse workers for repeated work, and choose shared workers or worklets for their specific jobs.

A worker is another place to run code

A worker is JavaScript running away from the page's main event loop. It can calculate, parse, or prepare data while the page keeps responding to input and drawing frames. A worker is useful when the work is large enough to repay the cost of starting it and passing data across its boundary.

Definition

A browser worker has its own global environment and event loop. The page and worker communicate by messages, usually through structured cloning, rather than by sharing ordinary JavaScript objects.

This lesson goes beneath the everyday Web Workers API lesson. The earlier lesson teaches how to use a worker. Here, the question is what work the browser must do before your first message is handled, and what it must do to carry a value across the boundary.

The previous lesson described agents and realms. A worker is one practical browser API built on that broad idea. The next lesson, SharedArrayBuffer & Atomics, changes the story by discussing memory that really can be shared.

One tiny worker round trip

Start with one message and one reply. The page makes a worker from a file named worker.js. It sends an order for two teas. The worker receives the object, calculates a total, and posts a small reply 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);};

Line 1 of main.js starts the worker script. Line 2 gives the page a handler for the later reply. Line 5 sends plain data. In worker.js, self is the worker's own global object, similar in role to a global object for that worker. The reply logs tea: 2 because the worker combines the two values from this tiny order.

Step through one worker message
Step 0 of 6Ready
Your turn: follow the blue line

This is a replay of instrumented lesson code, not an engine debugger. It shows the order of one structured-clone round trip.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
function handleMessage(order) {  return order.item + ": " + order.qty;}const delivered = structuredClone(message);const reply = handleMessage(delivered);console.log(reply);
CallStoreChangeResultRun = next line. 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 is not happening: the worker does not reach into the page and change a DOM element. It sends data back. The page decides what to render when its own event loop handles that reply.

An isolate and event loop per worker

An isolate is an engine's separate JavaScript world for values, globals, and garbage collection. Browser terminology and implementation details vary, but the useful model is firm: a worker has its own globals and its own queue of tasks. A long worker calculation does not run on the page's event loop.

The page document is not in a workerJavaScript
const pageName = "Asha";const workerName = "Mina";console.log(pageName);console.log(workerName);

Line 1 stores a page-side name and line 2 stores another ordinary value. Lines 3 and 4 print Asha and Mina. The names are small on purpose: a worker does not inherit either page variable. It starts with its own self, not the page's document or ordinary global object.

Real-life analogyHiring a helper

A helper needs their own desk and a list of tasks. They can work while you keep helping customers, but they cannot rearrange your desk unless you send back a request.

In real life: A helper gets a separate desk
In JavaScript: A worker gets separate globals
In real life: The helper has their own queue
In JavaScript: A worker has an event loop
In real life: You pass a written task
In JavaScript: Messages cross the boundary
In real life: You still arrange the room
In JavaScript: The page owns its DOM

Where the analogy stops: A worker is software, not a person. Its setup and message rules are exact platform behavior.

Separate event loops explain a common surprise: posting a message does not call the other side's handler immediately. It queues work for the receiver. Replies are queued too, so code should treat them as asynchronous results.

That separation protects page responsiveness, but it also removes convenient shared state. Put the values a worker needs into the message. Put the result it produces into the reply. The small round trip above does exactly that, so there is no hidden access to page variables.

Why starting a worker costs work

Starting a worker is not just calling a constructor. The browser must create the worker's JavaScript environment, arrange an event loop, fetch or load its script, parse it, and run its top-level setup. There is no useful fixed number of milliseconds: devices, cache state, script size, and browser work all change it.

A tiny worker-startup modelPop out in the code editor (opens in a new tab)JavaScript
const startupSteps = ["global", "event loop", "script"]; console.log(startupSteps.join(", "));// A worker must prepare these before handling its first message.

Line 1 names three pieces of preparation in a teaching model. Line 3 prints global, event loop, script. A browser does not literally use this array, but the output reminds us why worker startup is real work before the first order message can run.

This is why a worker can be a poor choice for a tiny calculation. The page pays setup, then message delivery, then reply delivery. For repeated jobs such as image thumbnails or search indexing, keeping a small group of workers alive can amortize that setup cost.

Practical rule

Do not start one worker for each small task. Reuse a small worker pool when a stream of similar jobs is genuinely expensive enough to need background work.

A pool is a design choice, not an automatic win. It needs a job queue, cancellation policy, error handling, and a limit so it does not compete with the rest of the browser. The next practical lesson is Parallel patterns with workers.

Reuse two workers for repeated 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 1 names two jobs and line 2 keeps two workers available. Lines 4 through 7 assign each job to one existing worker. Line 9 prints worker-1,worker-2. This is only a deterministic queue model; a real pool must still wait for each worker's reply before assigning its next job.

Structured clone copies data

Structured clone is the platform algorithm that copies many JavaScript and web values for messages. It walks supported data and creates a separate value on the receiving side. A small order is cheap to reason about. A large nested array means more values for the algorithm to visit and reproduce.

A clone stays independentPop 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 creates an object with an array. Line 2 makes a separate copy. Line 3 changes the original array after the copy exists. Line 4 prints hot, not hot,large, because the copied array did not change with the original.

Unsupported values throw DataCloneErrorPop out in the code editor (opens in a new tab)JavaScript
try {  structuredClone(() => "tea");} catch (error) {  console.log(error.name);}

Functions cannot be cloned because a function carries executable code and surrounding details that a generic message format does not reproduce. DOM nodes cannot be cloned into a worker either: they belong to a document the worker does not have. This snippet prints DataCloneError.

Model copy work before you optimize

A byte count is not the whole cost of structured cloning, but it gives a useful first question: how much data crosses this boundary, and how often? A message with a nested object graph also needs traversal. A large message every animation frame can defeat the reason you moved work away from the page.

Playground: model one message boundary
Message cost modelJavaScript
const messageSize = 12;const mode = "copy"; // change to "transfer"const report = modelMessageCost(messageSize, mode); console.log(report.copyWork);console.log(report.senderHasData);
Modeled resultcopy
copy work12

Work used to duplicate supported values.

sender datastill owned

The sender keeps a copy only in copy mode.

receiver bytes12

The receiver gets a value of this size in either mode.

Try it yourself
Choose how the message crosses the boundary

A 12-unit message is copied. The sender keeps its data, and the model performs 12 units of copy work.

This is a teaching cost model. Real cost also depends on object shape, platform work, and which data types the browser supports.

Change the message size, then switch between copy and transfer. Copy mode says the sender keeps its data and the model performs work proportional to the message size. Transfer mode says the receiver gets the data but the sender no longer owns it. This is a teaching model, not a timing benchmark.

First make the message smaller when you can. Send an ID, a small result, or a changed slice rather than a full application state. Then measure the actual application, because object shape and browser support matter more than a guessed timing.

Transferables move ownership

A transferable is a value whose backing resource can move to another context. For an ArrayBuffer, passing a transfer list tells the platform to move the bytes instead of copying them. The sender's buffer is detached after the move.

Transfer an ArrayBufferPop 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 allocates eight bytes. Line 2 moves the buffer. Line 3 prints 0 because the sender no longer owns usable bytes. Line 4 prints 8 because the received buffer owns them. The same ownership rule applies with postMessage(buffer, [buffer]).

Step through a buffer transfer
Step 0 of 4Ready
Your turn: follow the blue line

This replay calls the lesson's real transfer helper. A transfer moves ownership; it does not make two buffers share the same bytes.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
const received = structuredClone(buffer, { transfer: [buffer] });console.log(buffer.byteLength);console.log(received.byteLength);
CallStoreChangeResultRun = next line. 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.

Transfer is not shared memory. It is a handoff. Use it for a large binary payload that has one next owner. If both sides need to observe the same storage, that is a different design with SharedArrayBuffer, synchronization, and the memory model.

Shared workers and worklets

A SharedWorker can serve several same-origin pages or tabs. Each client talks through a MessagePort. This is useful when related tabs should coordinate one connection or one small background service, but it still has a message boundary and lifecycle details to design.

Open one shared-worker portJavaScript
const worker = new SharedWorker("shared-worker.js");const port = worker.port; port.start();port.postMessage({ type: "open-cart" });

Line 1 gets the shared worker by script name. Line 2 takes this page's port. Line 4 starts that port, and line 5 sends a small request. The code is marked as a browser API fragment because a lesson sandbox cannot promise another tab or the browser's shared-worker lifecycle.

A worklet is different. It is a lightweight module that the browser runs inside a specific pipeline. Audio worklets participate in audio processing; paint worklets participate in painting. They are narrow tools with pipeline rules, not general replacements for dedicated workers.

Worker shapes have different jobs
ToolWho connects or calls it?Good use
Dedicated WorkerOne page creates it and talks with postMessage.Background parsing, calculation, or image work for that page.
SharedWorkerSeveral same-origin tabs can connect through ports.A shared connection or coordinator for related tabs.
WorkletA small browser module runs inside a specific pipeline.Audio processing or paint work where the browser calls your code.
Main threadOwns the document and user-facing event loop.DOM work, rendering coordination, and user interaction.

Pick the smallest API that matches the job. A dedicated worker fits one page's background task. A shared worker fits coordination between same-origin clients. A worklet fits browser pipeline work. None grants access to another page's DOM.

Choose a worker shape for a real app

Imagine a product search page that receives a large catalog once, then filters it after each keystroke. Starting a new worker on every keypress wastes startup work. Sending the full catalog back and forth repeats cloning work. Keeping one worker and sending a small query can be a better boundary.

Write a small message protocol: what request is sent, what reply is expected, what happens if the page changes its mind, and who owns any binary data. Give jobs an ID so an old reply cannot overwrite a newer search result. Terminate or recycle workers when the work is really finished.

Ignore a stale search replyJavaScript
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 newest job ID. Lines 3 through 6 give every outgoing search a newer ID. Line 9 rejects a reply that belongs to an older request. This small rule matters when a user types quickly: an older, slower result must not replace the newest result on the page.

Copy, transfer, or reject?
  • { item: "tea", qty: 2 }
  • A large plain array
  • ArrayBuffer with a transfer list
  • An ImageBitmap passed as transferable
  • () => "tea"
  • A DOM button node
Try it yourself
0 of 6 correct

Sort each value by what happens when it crosses a worker message boundary.

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

The Node-only probe below is real runtime evidence that a Node worker_threads worker receives a message and replies. The browser API has a different module name, but the independent message-and-reply shape is the same idea.

Node-only worker_threads reply probeJavaScript
import { Worker } from "node:worker_threads";

const worker = new Worker(
  `import { parentPort } from "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 });

Common worker misconceptions

  • “A worker can touch the page DOM.” It cannot. Send a result and let the page update its own document.
  • “Messages share my object.” Ordinary message values are copied, so later mutations do not cross over.
  • “Transfer is just a faster clone.” Transfer changes ownership and detaches the sender's buffer.
  • “More workers always means faster.” Startup, messages, memory, and CPU contention can erase the gain.
  • “A worker pool is only an optimization detail.” Its queue and cancellation rules are part of application correctness.
Ideas that look similar but are not
TermWhat it meansDo not confuse it with
WorkerA separate JavaScript global environment and event loop.A second copy of the page DOM.
Structured cloneA deep, supported-data copy used for messages.A live shared reference to the original object.
TransferA move of ownership for a transferable object.A faster copy that leaves the sender usable.
Worker poolA small reused set of workers for repeated jobs.One new worker for every button click.

The simple check is ownership. Ask where a value lives now, whether it was copied or moved, and which event loop will handle the next message. That question prevents most worker-boundary confusion.

Practice exercises

Exercise 1 · Warm-upPredict the copied value

What does this structured-clone 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 detached length

    What does the sender-side buffer length print after transfer?

    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 image jobs. What should it reuse instead of creating one new worker per click?

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

        Exercise 4 · PracticeName the missing page API

        What page feature is unavailable inside a normal worker?

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

          Exercise 5 · ChallengeMove a large binary result

          Your worker produces a large binary result and the page no longer needs the worker's copy. Which data action fits?

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

            Exercise 6 · ChallengeCoordinate several tabs

            Which worker API can serve several same-origin tabs?

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

              Check your understanding

              For each question, name the boundary first: separate event loops, copied values, moved ownership, or a browser-specific worker shape.

              Worker internals quiz · 7 questionsScore: first tries count
              1. Question 1 of 7What does a dedicated worker get for itself?

                Choose an answer to see the explanation.

              2. Question 2 of 7What does this structured-clone 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 avoid one new worker per small task?

                Choose an answer to see the explanation.

              5. Question 5 of 7Which value causes DataCloneError here?

                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 SharedWorker 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 separate globals and an event loop; it cannot use the page DOM.
              • Starting a worker creates setup work, so reuse workers for repeated expensive jobs.
              • Structured clone creates independent supported values and becomes more work as the message graph grows.
              • Transferables move ownership; an ArrayBuffer sender becomes detached with a byteLength of zero.
              • Shared workers coordinate same-origin clients through ports, while worklets run inside focused browser pipelines.

              Remember the one-liner.
              A worker buys a separate event loop, but startup and every message boundary have a cost.

              Coming next: SharedArrayBuffer & Atomics, where two agents can intentionally share memory and must coordinate access carefully.

              CompleteFrontend Clear concepts. Working examples.