You embed a third-party widget in an iframe and exchange dozens of messages per second in both directions. Why set up a `MessageChannel` and hand one port to the frame instead of calling `window.postMessage` with an origin check on every message?
answer
- window message is a broadcast bus
- hand over a private pipe
- MessageChannel gives port1 and port2
- transfer the port once, verified
- addEventListener needs port.start()
basics
~20 sA window's message event is a shared bus every frame and opener can post to, so each message needs revalidation. A MessageChannel gives two entangled MessagePorts; after one verified handover, traffic flows on a private pipe only the two peers hold, with no per-message origin checks.
solid answer
~40 s`window.postMessage` targets a window, and a window's `message` listener hears from every context that holds a handle to it, so every single message must be re-checked for origin and source and shape. `new MessageChannel()` creates `port1`/`port2`, two entangled endpoints. You do one verified `window.postMessage` handshake and transfer `port2` in the transfer list; the peer picks it up from `event.ports[0]`. From then on the port is a capability: only the entangled pair can send or receive on it, so `port.postMessage(data)` takes no `targetOrigin` and the receiving handler has no origin to check — `event.origin` is the empty string on port messages. Two details matter operationally: with `addEventListener` you must call `port.start()` or nothing is delivered (assigning `onmessage` starts it implicitly), and you `close()` the port on teardown.
code
javascript · 18 lines// Embedder: verified handshake, then all traffic moves onto the port
const WIDGET_ORIGIN = 'https://widget.example.com';
const iframe = document.getElementById('widget');
window.addEventListener('message', function onReady(e) {
if (e.origin !== WIDGET_ORIGIN || e.source !== iframe.contentWindow) return;
if (e.data?.type !== 'ready') return;
window.removeEventListener('message', onReady);
const channel = new MessageChannel();
channel.port1.addEventListener('message', (ev) => {
// ev.origin is '' here - identity was settled at handover
if (ev.data?.type === 'tick') render(ev.data.value);
});
channel.port1.start(); // required with addEventListener
iframe.contentWindow.postMessage({ type: 'connect' }, WIDGET_ORIGIN, [channel.port2]);
});go deeper
Know that new MessageChannel() produces two connected ports, that one is sent to the other side, and that messages posted on one arrive on the other.
Explain the handover: one verified window message carries port2 in the transfer list, the peer reads it from event.ports[0], and start() is required unless onmessage is assigned.
Argue the security shape — origin verification happens once at handover, so the port becomes a capability — and cover lifetime: neutering on transfer, ports dying with their document, and closing on teardown.
Decide when a channel-based protocol is worth its complexity versus plain window messaging, and define the house pattern for embed communication: per-request channels, versioned envelopes, and what happens when a peer disappears mid-conversation.
## The problem with the window message bus `otherWindow.postMessage(data, targetOrigin)` addresses a *window*, and the window's `message` event is a single shared target. Every embedded frame, the opener, and any popup can post to it, so a hardened receiver must, for every message, compare `event.origin` against an allowlist, often compare `event.source` against a specific frame, and then validate the payload. That is correct but it has costs at volume: the checks run on every message, unrelated third-party frames wake your handler, and the security of the channel depends on nobody ever adding a second, sloppier `message` listener to the same page. Nothing in the design lets you say "this conversation is between these two documents". ## What MessageChannel gives you ```js const channel = new MessageChannel(); // channel.port1 and channel.port2 are entangled MessagePort objects ``` The two ports are entangled: a message posted on one is delivered to the other and to nothing else. A `MessagePort` is a transferable object, so you can hand one end to another context. Once the peer holds it, the pair forms a private channel that is not reachable from any third document, even one on the same origin. ## The handshake You still need one window message to bootstrap, because that is the only way to reach a document you have not yet met — and that first exchange is exactly where the origin verification belongs: ```js // Embedder, after the widget announced it is ready const channel = new MessageChannel(); channel.port1.onmessage = (e) => handleFromWidget(e.data); iframe.contentWindow.postMessage({ type: 'connect' }, WIDGET_ORIGIN, [channel.port2]); // Widget window.addEventListener('message', (e) => { if (e.origin !== EMBEDDER_ORIGIN) return; // verify once, here if (e.data?.type !== 'connect') return; const port = e.ports[0]; port.onmessage = (ev) => handleFromHost(ev.data); port.postMessage({ type: 'hello' }); }); ``` The transferred port arrives in `event.ports`. Being transferred means the sending side's reference to `port2` is neutered — you keep `port1` and give away `port2`; you cannot keep both ends usable in one document. After this, the trust argument is structural rather than per-message: the only document that can post on that port is the one you handed it to, at an origin you checked. That does not mean the payload is trustworthy — a compromised peer sends whatever it likes — so message-shape validation stays. What disappears is the per-message question of *who* is talking. ## start() and the queue A port does not deliver until it is started. Assigning `port.onmessage = fn` implicitly calls `start()`. Using `port.addEventListener('message', fn)` does **not** — you must call `port.start()` yourself, and forgetting it is the classic "my channel silently does nothing" bug. Messages posted before the port is started are queued rather than lost, so the peer can send immediately after handover without a race. ## origin and source on port messages A `MessageEvent` delivered through a port has `origin` set to the empty string and `source` set to `null`. This surprises people who copy their window handler over: `if (e.origin !== TRUSTED) return;` rejects every message. There is no origin to report because the port has exactly one counterparty; the identity check happened at handover time. ## Lifetime and teardown A port belongs to the document that holds it. If the iframe navigates or is removed, its end of the channel goes away with that document and your messages simply stop arriving — with no event to tell you. If the connection matters, keep a heartbeat or re-run the handshake on the frame's `load`. Call `port.close()` when you tear the feature down; an unclosed port keeps its peer document reachable and can hold objects alive longer than you expect. Ports can also be transferred onward — you can hand a port to a worker so the widget talks to your worker directly without relaying through the main thread, which is where the pattern starts paying for itself on high-volume traffic. ## When not to bother For a handful of messages — a config push, a resize notification — plain `window.postMessage` with strict checks is simpler and easier for a reviewer to verify. Reach for a channel when the traffic is bidirectional and continuous, when you need several independent conversations with the same peer (one channel per request stream), or when you want a request/response pattern: create a fresh `MessageChannel` per request, send `port2` along with the request, and resolve a promise on the single reply, then close it. That is the shape `ServiceWorker`-adjacent code and RPC wrappers use, and it removes correlation-id bookkeeping entirely.
- After receiving a port via `event.ports[0]` and calling `port.addEventListener('message', h)`, nothing arrives. Why?The port has not been started. A `MessagePort` queues incoming messages until `start()` is called; assigning `port.onmessage = h` calls it implicitly, but `addEventListener` does not. Add `port.start()` after registering the listener and the queued messages flush immediately.
- Should the handler on a `MessagePort` still validate `event.origin`?No — it is the empty string on port messages, and `event.source` is null, so an origin comparison rejects everything. A port is entangled with exactly one peer, and the identity check belonged to the window message that carried the port. Keep validating the payload's shape, since a compromised peer can still send anything.
- What happens to the channel when the widget iframe navigates or is removed?That document's end of the channel disappears with it. Your port stays alive but its peer is gone, so messages go nowhere and no event tells you. Detect it with a heartbeat or by re-running the handshake on the frame's load event, and call `port.close()` on your side during teardown so nothing is held alive.
- How would you build request/response on top of this rather than tracking correlation ids?Create a fresh `MessageChannel` per request, transfer `port2` alongside the request message, and resolve a promise on the first message received on `port1`, then close it. Each request gets its own private one-shot channel, so replies cannot be mismatched and there is no id table to garbage-collect.
Window messaging is shouting across a room where anyone may shout back; a MessagePort is handing that one person a private phone line after you have checked who they are.
saying these in an interview costs you the question
- Checking event.origin on MessagePort messages
- Using addEventListener on a port without calling start()
- Expecting to keep both ports usable after transferring one
- Assuming a port makes the payload itself trustworthy
- Thinking port.postMessage takes a targetOrigin argument