How workers work
Learn what workers create, why startup and structured cloning cost work, when transfers move ownership, and where shared workers fit.
- 01Trace a worker boundaryExplain the separate globals, event loop, message delivery, and reply in one small worker round trip.
- 02Choose copy or transferPredict when structured cloning duplicates data and when a transferable moves ownership away from the sender.
- 03Spend 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.
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.
This is a replay of instrumented lesson code, not an engine debugger. It shows the order of one structured-clone round trip.
script
function handleMessage(order) { return order.item + ": " + order.qty;}const delivered = structuredClone(message);const reply = handleMessage(delivered);console.log(reply);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.
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.
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.
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.
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.
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.
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.
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.
const messageSize = 12;const mode = "copy"; // change to "transfer"const report = modelMessageCost(messageSize, mode); console.log(report.copyWork);console.log(report.senderHasData);12Work used to duplicate supported values.
still ownedThe sender keeps a copy only in copy mode.
12The receiver gets a value of this size in either mode.
A 12-unit message is copied. The sender keeps its data, and the model performs 12 units of copy work.
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.
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]).
This replay calls the lesson's real transfer helper. A transfer moves ownership; it does not make two buffers share the same bytes.
script
const received = structuredClone(buffer, { transfer: [buffer] });console.log(buffer.byteLength);console.log(received.byteLength);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.
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.
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.
{ item: "tea", qty: 2 }- A large plain array
ArrayBufferwith a transfer list- An
ImageBitmappassed as transferable () => "tea"- A DOM button node
Sort each value by what happens when it crosses a worker message boundary.
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.
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.
| Term | What it means | Do not confuse it with |
|---|---|---|
| Worker | A separate JavaScript global environment and event loop. | A second copy of the page DOM. |
| Structured clone | A deep, supported-data copy used for messages. | A live shared reference to the original object. |
| Transfer | A move of ownership for a transferable object. | A faster copy that leaves the sender usable. |
| Worker pool | A 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
What does this structured-clone program print?
const cart = { item: "tea" };
const copy = structuredClone(cart);
cart.item = "coffee";
console.log(copy.item);It prints tea. Structured clone makes an independent object, so changing cart.item later does not update copy.item.
What does the sender-side buffer length print after transfer?
const buffer = new ArrayBuffer(4);
structuredClone(buffer, { transfer: [buffer] });
console.log(buffer.byteLength);It prints 0. The original buffer no longer owns the four bytes after the transfer.
A photo editor has many similar image jobs. What should it reuse instead of creating one new worker per click?
Use a small worker pool or reused workers. That spreads startup cost over repeated jobs and gives the app one place to manage its queue.
What page feature is unavailable inside a normal worker?
The DOM is unavailable. A worker cannot use the page's document; the main thread must update visible elements.
Your worker produces a large binary result and the page no longer needs the worker's copy. Which data action fits?
Transfer ownership of the ArrayBuffer. The receiver gets the bytes and the sender becomes detached, making the handoff explicit.
Which worker API can serve several same-origin tabs?
Use a SharedWorker when several same-origin tabs should connect to one coordinator through ports.
Check your understanding
For each question, name the boundary first: separate event loops, copied values, moved ownership, or a browser-specific worker shape.
Question 1 of 7What does a dedicated worker get for itself?
Choose an answer to see the explanation.
Question 2 of 7What does this structured-clone 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 avoid one new worker per small task?
Choose an answer to see the explanation.
Question 5 of 7Which value causes
DataCloneErrorhere?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 SharedWorker 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 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.