skip to content

Worker Message Passing

You will learn the only channel a worker has — postMessage — and the patterns that turn a fire-and-forget message into a usable request/response API. Interviewers ask about the cloning cost that makes chatty workers slower than no worker.

on this pageshow

questions

5

In a browser, a page calls worker.postMessage(payload) on a dedicated Web Worker and then immediately mutates payload. Does the worker see the mutation? Explain what the worker actually receives and how it reads it.

level: juniorimportance: must knowfreq 52%

answer

  1. two threads, no shared objects
  2. the value is reconstructed, not referenced
  3. snapshot taken when you call it
  4. later edits never cross
  5. handler gets an event, not the payload

basics

~20 s

No. postMessage sends a copy made at the moment of the call, so later edits to the original never reach the worker. The worker gets a message event asynchronously and reads the copy from event.data; the two threads share no objects.

solid answer

~50 s

`postMessage` is not a shared reference — it runs the structured clone algorithm on the value **synchronously, at the call**, and queues the resulting copy for delivery. So mutating `payload` on the next line changes only the page's object; the worker still receives what the value looked like when `postMessage` returned. On the worker side the copy arrives as a `MessageEvent` on the global scope, and you read it with `self.onmessage = (event) => { ... event.data ... }` or `self.addEventListener('message', ...)`; replying is the mirror image, `self.postMessage(result)`, read on the page as `event.data`. Delivery is asynchronous: the message becomes a task on the receiver's event loop, so nothing has happened at the instant `postMessage` returns. The practical consequence is that there is no shared mutable state between page and worker over this channel — every exchange is a copy in one direction, and whichever side owns a piece of state has to keep owning it.

code

javascript · 9 lines
javascript
const worker = new Worker('/echo.worker.js');
const payload = { count: 1, items: [] };

worker.addEventListener('message', (event) => {
  console.log(event.data.count); // 1, not 2
});

worker.postMessage(payload);
payload.count = 2; // too late: the copy was taken above

go deeper

for a junior

Be ready to say plainly that postMessage copies the value: the worker receives its own object, and edits made after the call never cross. Remember the payload is on event.data, not the handler argument itself.

for a middle

Explain that the copy is produced synchronously at the call and delivered later as a task, that shared references and cycles inside one message survive, and that identity across separate messages does not.

for a senior

Show you design around copy semantics: decide which side owns long-lived state, define an explicit message contract, and handle failure modes such as a DataCloneError on post and a messageerror on receive rather than assuming delivery.

for a principal

Own the thread boundary as an API surface. Argue for payload shapes that stay cheap to copy and tolerant of version skew between page and worker code, and make state ownership explicit so the copy never becomes a hidden source of divergence.

## The only channel a worker has A dedicated Web Worker runs on its own thread with its own global scope. It cannot reach into the page's variables and the page cannot reach into its. The single sanctioned channel between them is message passing: the page holds a `Worker` object and calls `worker.postMessage(value)`; the worker calls `self.postMessage(value)` back. Each side listens for a `message` event and reads the payload from the event's `data` property. ```js // page const worker = new Worker('/echo.worker.js'); worker.onmessage = (event) => console.log(event.data); worker.postMessage({ count: 1 }); // inside echo.worker.js self.onmessage = (event) => self.postMessage(event.data.count + 1); ``` Note the shape: the handler receives an **event object**, not the payload. Reading the first argument as if it were the message itself (`worker.onmessage = (msg) => msg.count`) is the single most common beginner bug here. ## Copy, not reference When you call `postMessage`, the browser does not hand the receiving thread a pointer. It runs the *structured clone* algorithm: it walks the value you passed and builds an independent reconstruction of it for the other side. That reconstruction is what lands in `event.data`. Two things follow immediately. First, **the copy is taken at the call**, synchronously, before `postMessage` returns. Anything you do to the original afterwards is invisible to the receiver: ```js const payload = { count: 1 }; worker.postMessage(payload); payload.count = 2; // too late — the clone already recorded 1 ``` Second, **the two graphs are permanently disconnected**. The worker can mutate its copy freely; the page will never see it. There is no live binding, no proxying, no write-back. If both sides need to agree on evolving state, they have to exchange messages about it, or one side has to be the owner and the other has to ask. ## What survives inside one message Within a *single* message the clone is faithful about the shape of the graph, not just its leaves. If the same object appears twice inside the value you post, the receiver also gets it twice as one shared object; cyclic structures are reproduced as cycles rather than causing infinite recursion. That is why structured clone exists at all instead of the browser simply calling `JSON.stringify` for you. What it does **not** do is preserve identity *across* messages. Post the same object twice and the receiver gets two unrelated copies. Post a value and then post it again after mutating it, and the receiver sees two snapshots, in order. Not every value can be cloned; posting something the algorithm cannot reconstruct throws a `DataCloneError` at the `postMessage` call rather than failing silently later. Symmetrically, if a message arrives that the receiver cannot deserialize, the receiver gets a `messageerror` event instead of `message` — worth wiring up in production code, because otherwise such a failure is completely invisible. ## Delivery is a task, not a call `postMessage` returns as soon as the copy is made. The message is then queued on the receiving side and dispatched as a task on that thread's event loop. So the receiver's handler runs some time later, and — importantly — only when the receiving thread is free to run a task. If the worker is in the middle of a long synchronous computation, your message sits in its queue until that computation finishes. Message passing does not interrupt anything. Order is preserved per channel: messages posted from one side arrive in the order they were posted. There is no ordering guarantee *between* different channels, and no acknowledgement — `postMessage` is fire-and-forget, so if you need to know that the other side received or processed something, that has to be an explicit reply message you design yourself. ## Designing around it Because every exchange is a copy, the interesting design question stops being "how do I share this object" and becomes "who owns this state". The usual answer is that the worker owns the expensive thing — a parsed index, a loaded dataset, an initialized engine — and the page sends small instructions and receives small results. The moment you find yourself shipping the same large structure back and forth so both sides can "see" it, you have chosen the wrong owner, and you will pay for the copy on every message.

  • How does the worker send something back, and how do you know on the page which request a reply belongs to?
    The worker calls `self.postMessage(value)` and the page reads it from `event.data` on the `Worker` object's `message` event. Nothing in the platform correlates a reply with a request — a single handler sees every reply. If several requests can be in flight, you have to put a correlation field in the message yourself and match replies to it.
  • What happens if you post a value the browser cannot clone?
    `postMessage` throws a `DataCloneError` DOMException synchronously at the call site, so the message is never queued and the receiver hears nothing. That is a useful property: the failure surfaces on the thread that made the mistake, with a stack, rather than as a silently missing message on the other side.
  • If the worker is busy running a long computation, what happens to a message the page posts to it?
    It is queued and delivered as a task once the worker's thread finishes what it is doing. Messages never interrupt running code — the worker is single-threaded internally too. This is why a "cancel" message cannot stop a synchronous loop already in progress; the worker has to check for cancellation between chunks of work, or the page has to terminate it.

saying these in an interview costs you the question

  • Says page and worker share the same object
  • Expects the worker to see mutations made after posting
  • Thinks postMessage delivers synchronously to the other thread
  • Reads the handler's argument directly instead of event.data
  • Assumes a posted message interrupts code the worker is running

context

open as a page

A dedicated Web Worker replies through a single message event on the page, so every reply lands in the same handler. How would you build a promise-based request/response API on top of postMessage so that several concurrent callers each get their own answer?

level: middleimportance: must knowfreq 46%

basics

~20 s

Attach a unique id to every outgoing message and keep a map from id to that request's promise resolvers. The worker echoes the id back in its reply; the single message handler looks up the id, settles that promise, and deletes the entry.

open as a page

In a browser, how do you use MessageChannel and MessagePort to give two parties — for example a page and a Web Worker, or two workers — a private two-way link, and what has to happen before messages start flowing on a port?

level: middleimportance: should knowfreq 28%

basics

~20 s

Create a MessageChannel, keep port1, and hand port2 to the other party inside a postMessage whose transfer list contains that port. Each side then posts on its own port. A port that uses addEventListener must also have start() called, or messages queue and never fire.

open as a page

A team moved a per-item computation into a dedicated Web Worker, posting each item to the worker and posting each result back, and the page got slower rather than faster. What cost are they paying on every message, and how would you fix it?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Each message pays a structured clone: a synchronous serialize on the sender's thread plus a deserialize on the receiver's, twice per round trip, plus a task dispatch on each side. With tiny work per item that overhead dominates. Fix it by batching many items into one message.

open as a page

In a browser, what problem does BroadcastChannel solve that posting to a specific worker or holding a MessagePort does not, and what are its delivery rules?

level: middleimportance: nice to knowfreq 26%

basics

~20 s

BroadcastChannel is same-origin fan-out by name rather than by reference: any context that opened a channel with the same name receives the message, with no handle to each other. The sending object never receives its own message, and there is no history for late joiners.

open as a page