skip to content

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%

answer

  1. two ends, entangled
  2. hand one end away
  3. it must travel in the transfer list
  4. assigning onmessage does something extra
  5. addEventListener needs a nudge

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.

solid answer

~40 s

`new MessageChannel()` creates two entangled ports, `port1` and `port2`: anything posted on one is delivered to the other. You keep one and give the other away — a port is transferable, so you include it both in the message body and in the transfer list, as in `worker.postMessage({ port: channel.port2 }, [channel.port2])`. After that the two holders talk directly, without going through the `Worker` object, which is what makes ports useful for wiring two workers together, for handing one consumer its own private channel, or for giving a single request its own reply path. The catch that trips people up is starting: assigning `port.onmessage` implicitly starts the port, but registering with `port.addEventListener('message', ...)` does not — you must call `port.start()` yourself, otherwise incoming messages queue silently forever. Call `port.close()` when the link is finished.

code

javascript · 9 lines
javascript
const channel = new MessageChannel();

channel.port1.addEventListener('message', (event) => {
  console.log('reply from worker:', event.data);
});
channel.port1.start(); // required: addEventListener does not start the port

const worker = new Worker('/pipe.worker.js');
worker.postMessage({ type: 'port', port: channel.port2 }, [channel.port2]);

go deeper

for a junior

Know that MessageChannel creates two connected ports and that posting on one delivers to the other. Recognise MessagePort as something that can be handed to another context rather than copied.

for a middle

Explain the wiring end to end: construct the channel, transfer one port in both the message and the transfer list, and start the port explicitly whenever you use addEventListener instead of onmessage.

for a senior

Show judgment about topology — using ports to connect two workers directly so traffic never crosses the main thread, or to give a streaming request its own reply channel — and be clear that ports do not reduce per-message clone cost.

for a principal

Frame ports as capability handoff: giving a component a port grants exactly one narrow ability and composes across boundaries. Weigh that against per-channel allocation and the operational cost of many independent links to observe and tear down.

## What the Worker object already gives you A `Worker` object is itself a message endpoint: `worker.postMessage()` on the page, `self.postMessage()` inside, and one `message` event on each side. For a page talking to one worker that is often all you need. Its limitation is that it is a single shared pipe belonging to the two ends of that specific relationship. Everything multiplexes through one handler on each side. And there is no way for the page to introduce two *other* parties to each other — two workers cannot talk directly, because neither holds a `Worker` object for the other. ## Entangled ports `MessageChannel` solves both. Constructing one produces a pair of `MessagePort` objects: ```js const channel = new MessageChannel(); channel.port1.onmessage = (event) => console.log('port1 got', event.data); channel.port2.postMessage('hello'); // arrives at port1 ``` The ports are *entangled*: a value posted on `port2` is delivered to `port1` and vice versa. Neither port is special; the names are just the two ends. Each port has `postMessage()`, a `message` event, a `messageerror` event for payloads that cannot be deserialized, `start()`, and `close()`. By itself that is a channel inside one context, which is occasionally useful on its own. The power comes from being able to move one end somewhere else. ## Handing an end over A `MessagePort` is transferable, which means it can be moved across a boundary rather than copied — the same port object, now owned by the other side. To do that you put it in the message **and** in the transfer list: ```js const channel = new MessageChannel(); const worker = new Worker('/pipe.worker.js'); worker.postMessage({ type: 'port', port: channel.port2 }, [channel.port2]); ``` Both halves matter. The transfer list alone does not put the port into `event.data`; putting it only in the body without the transfer list fails, because a port cannot simply be copied. Inside the worker, `event.data.port` is a live `MessagePort` entangled with the page's `port1`, and from then on the two communicate directly. The same move is how you connect two workers: create a channel on the page, transfer `port1` to worker A and `port2` to worker B, and the page drops out of the conversation entirely. Messages between them never touch the main thread — which is the real prize, because the main thread is the one you are trying to keep free. ## The start() rule This is the classic interview detail, and the classic bug. A port arrives in a *disabled* state, buffering anything sent to it. It is enabled in one of two ways: - Assigning to `port.onmessage` **implicitly calls `start()`**. Everything just works, which is why most tutorial code uses `onmessage`. - Registering with `port.addEventListener('message', handler)` does **not**. You must call `port.start()` explicitly. So the symptom is: you modernized a handler from `onmessage` to `addEventListener`, and the port went silent — no error, no warning, messages simply queue. The rule to remember is that `addEventListener` on a port always needs a matching `start()`. `close()` is the opposite end of the lifecycle: it disentangles the port so nothing more is delivered. Ports left open keep their entanglement alive, so closing them when a component unmounts or a task finishes is ordinary hygiene, not an optimization. ## What ports buy you **Isolation of conversations.** Each consumer holds its own port, so its messages are not interleaved with everyone else's in one giant handler with a `switch` on `type`. **Structural correlation.** Transfer a fresh port with a request and the reply comes back on that port. There is no id to match — the channel *is* the identity. This is especially good when a request produces a stream of replies (progress updates, incremental results) rather than a single answer. **Capability handoff.** Giving someone a port gives them exactly one ability: to talk to whatever is on the other end. That is a much narrower grant than exposing a general endpoint, and it composes — the receiver can pass the port on again. **Direct links between non-adjacent parties.** Two workers, or a worker and an embedded document, that otherwise have no reference to each other. ## Costs to be honest about Ports are not free. Each `MessageChannel` allocates two objects and an entanglement the browser must track, and messages posted on a port are still structured-cloned exactly as on any other channel — a port changes *who* is connected, not *how much a message costs*. Creating one channel per tiny request is a real allocation cost, which is why simple one-shot request/response APIs usually stay with a single channel and a correlation id, and reserve ports for streams, for private sub-conversations, and for connecting parties that cannot otherwise reach each other.

  • Why does a port wired up with addEventListener stay silent until you call start()?
    Ports arrive disabled and buffer incoming messages until enabled, so that a receiver can install its handler before anything is dispatched. Assigning `port.onmessage` enables the port as a side effect of the assignment; `addEventListener` has no such special case, so an explicit `port.start()` is required. Without it the messages are queued, not lost, and nothing is logged.
  • How would you let two dedicated Web Workers talk to each other without routing every message through the page?
    Create a `MessageChannel` on the page and transfer one port to each worker with `postMessage(msg, [port])`. Each worker keeps its port, starts it, and posts on it. From then on their traffic is direct and never occupies the main thread — which is usually the whole point, since the main thread is the resource you are protecting.
  • Does using a MessagePort avoid the copy that a normal postMessage performs?
    No. Ports change the topology, not the cost model: values posted on a port are structured-cloned just as on a Worker object. What ports give you is a private, directly-addressed link and, when transferred with a request, structural correlation of replies. If large payload copies are your bottleneck, ports on their own will not help.
  • When would you prefer a per-request MessageChannel over an id-and-map correlation scheme?
    When a request produces a stream of replies rather than one — progress events, incremental results — because the port is naturally a durable channel you close when done. Also when the reply channel must be handed to a third party. For plain one-shot calls the id scheme is cheaper, since each channel allocates two ports the browser must track.

saying these in an interview costs you the question

  • Puts the port only in the transfer list, not the message
  • Expects addEventListener on a port to work without start()
  • Thinks a port avoids the structured clone cost
  • Believes port1 and port2 have different capabilities
  • Never calls close() and leaks entangled ports

context