Who can hear this? A map of browser messaging

2025/08/02
browserweb-workerspostmessagemessagechannelfrontend

The browser gives you a dozen ways to send a message. window.postMessage, worker.postMessage, MessageChannel, BroadcastChannel, a service worker talking to its pages. People trip on which one to reach for, but the syntax was never the hard part. The hard part is two questions you rarely ask out loud:

  1. Who actually receives this?
  2. Can I make it reach only them?

Every messaging API is just an answer to those two. Line them up that way and the mess turns into a map.

First trap: "postMessage" is not one method

Before the map, clear this up, because it bites everyone. postMessage is a name shared by several different methods, each living on a different object:

someWindow.postMessage(msg, targetOrigin)  // on a Window  (iframe, popup, parent, opener)
worker.postMessage(msg)                     // on a Worker
port.postMessage(msg)                       // on a MessagePort
client.postMessage(msg)                     // on a service worker Client

The tell is targetOrigin. Only the Window version takes one, because the window on the other end might be a different origin and the browser wants you to name who you trust. The worker, port, and client versions have no targetOrigin: they are same-origin by nature.

So you cannot use window.postMessage to reach a worker, or worker.postMessage to reach an iframe. Same word, different methods on different objects. window.postMessage is strictly window to window: it reaches a window you already hold a handle to (an iframe you embedded, a popup you opened, your parent or opener), and the message lands on that window's message event.

The cast

Who can send and receive: a tab (window), an iframe or popup inside it, and the three workers.

  • Dedicated worker is a thread for one page.
  • Shared worker is one worker that several tabs connect to.
  • Service worker sits in front of a set of pages like a proxy (offline, caching, push).

The map: who hears what

MechanismConnectsWho hears it
window.postMessagea window to another window / iframe / popupbroadcast: every message listener in the receiving window, third-party scripts included
worker.postMessage (dedicated)a page and its own workerprivate 1:1, those two only
SharedWorker.portmany same-origin tabs to one workereach tab gets its own port; the worker can target one or loop
service worker client.postMessagea service worker to its pagesbroadcast to the clients it controls
MessageChannel portany two contexts you wire togetherprivate 1:1, only whoever holds the port
BroadcastChannelevery same-origin context on a named channelbroadcast to all subscribers

Read the right column. Most of these are broadcast.

The default is louder than you think

You call postMessage on an iframe and assume you sent one message to one recipient:

iframe.contentWindow.postMessage('hello', 'https://trusted.com');

You didn't. You delivered it to the receiving window's message event, and every listener in that window gets a copy:

// anywhere in the receiving page, e.g. a third-party analytics script:
window.addEventListener('message', e => console.log('I got it too:', e.data));

Same origin does not mean "your code." An injected script, an ad SDK, a dependency that page loaded are all in the same context, all able to listen. BroadcastChannel is the same story on purpose: anyone who joins the channel name hears everything.

So broadcast is the default. The real question is how you opt out of it.

Private means handing over a port

There is one way to send a message that only one chosen party can receive, and it works between any two contexts. Create a MessageChannel, keep one port, transfer the other:

const channel = new MessageChannel();
worker.postMessage('setup', [channel.port2]);   // give one end away
channel.port1.postMessage('this part is private');

The receiver picks up its end from event.ports[0], and from then on only the holder of that port receives. Other message listeners in the same context get nothing, because this never touched the window's message event. It went to the port.

You can hand that port to exactly one party, or pass it on to another. That is how you get a private line between any two contexts: a page and a worker, two workers, a page and an iframe.

Inside a worker: three tools, one matrix

window.postMessage is window-only, so a worker never uses it. Inside a worker you have three messaging tools, and all three workers can use all three. The only thing that changes is who postMessage is aimed at:

postMessageMessageChannelBroadcastChannel
Dedicated✅ to its page (self / worker.postMessage)
Shared✅ per tab, via port.postMessage
Service✅ to a client (client.postMessage / e.source)

MessageChannel and BroadcastChannel behave identically everywhere: new one, use it. Only postMessage shifts target by worker type, because a dedicated worker has one peer, a shared worker has many ports, and a service worker has many clients.

Picking one

  • Reach a window you have a handle to (iframe, popup)? window.postMessage. Everyone in that window hears it.
  • Talk to your own worker? worker.postMessage.
  • Across same-origin tabs, no central hub? BroadcastChannel.
  • A private line nobody else can read? MessageChannel, transfer a port.

postMessage and BroadcastChannel reach everyone in the receiving context. A transferred port reaches only its holder. Pick by who you want to hear it.

(Browser extensions are their own world. Components inside one extension talk over chrome.runtime messaging, a separate system scoped to that extension.)

Code for each, open the one you want:

Three ways to send

postMessage — window to window (iframe, popup, opener)

Only reaches a window you hold a handle to. The message lands on that window's message event, where every listener there hears it.

iframe.contentWindow.postMessage('hi', 'https://child.example');   // to an iframe
const popup = window.open('https://child.example');
popup.postMessage('hi', 'https://child.example');                  // to a popup
window.parent.postMessage('hi back', 'https://parent.example');    // iframe → parent
window.opener.postMessage('hi back', 'https://parent.example');    // popup → opener

It can't reach an unrelated tab (you hold no handle to it). That is BroadcastChannel's job.

MessageChannel — a private 1:1 port between any two contexts

Picture one string with two tin cans (port1, port2). You new it, keep one can, hand the other over; only the two holders hear it.

// page: keep one can, give the other to the worker
const channel = new MessageChannel();
worker.postMessage('here is your line', [channel.port2]);
channel.port1.onmessage = (e) => console.log('page got:', e.data);
channel.port1.postMessage('hello on the private line');
 
// worker: pick up the handed-over can
self.onmessage = (e) => {
  const port = e.ports[0];
  port.onmessage = (ev) => console.log('worker got:', ev.data);
  port.postMessage('reply, only the page hears this');
};

The same trick wires two workers directly. The page is only the matchmaker: it makes one channel, hands each worker an end, and steps out of the data path.

// page: introduce A and B, then leave
const ch = new MessageChannel();
workerA.postMessage('connect', [ch.port1]);
workerB.postMessage('connect', [ch.port2]);
 
// workerA.js (workerB.js is the mirror)
self.onmessage = (e) => {
  const peer = e.ports[0];
  peer.onmessage = (ev) => console.log('A got from B:', ev.data);
  peer.postMessage('hi B, straight to you');   // never passes through the page
};
BroadcastChannel — broadcast to everyone on a name

Every same-origin context that opens the same name joins the same bus; the sender does not hear its own message. Four of them, each in its own context, all on 'app':

// page 1
const bc = new BroadcastChannel('app');
bc.onmessage = (e) => console.log('page1 heard:', e.data);
 
// page 2 (another tab)
const bc = new BroadcastChannel('app');
bc.onmessage = (e) => console.log('page2 heard:', e.data);
 
// a service worker
const bc = new BroadcastChannel('app');
bc.onmessage = (e) => console.log('sw heard:', e.data);
 
// a dedicated worker — this one shouts
const bc = new BroadcastChannel('app');
bc.postMessage({ type: 'logout' });

One postMessage from the worker, and page1, page2, and the service worker all hear logout. The only context that does not is the one that sent it.

The three workers

Dedicated worker — one page's own thread

Talks only to the page that created it.

// page
const worker = new Worker('crunch.js');
worker.postMessage({ rows: bigArray });
worker.onmessage = (e) => render(e.data);
 
// crunch.js
self.onmessage = (e) => self.postMessage(heavyWork(e.data.rows));
Shared worker — many tabs, one worker

Each tab connects on its own port; the worker keeps the ports in a Map keyed by an id the tab reports. No port1/port5 numbering: they are separate port objects, you key them yourself.

// each tab: connect and say who you are
const sw = new SharedWorker('hub.js');
sw.port.start();
sw.port.postMessage({ register: 'tabA' });   // tabB, tabC, ... do the same
sw.port.onmessage = (e) => console.log('got:', e.data);
 
// hub.js: one port per tab, kept by id
const tabs = new Map();
onconnect = (e) => {
  const port = e.ports[0];
  port.onmessage = (ev) => { if (ev.data.register) tabs.set(ev.data.register, port); };
  port.start();
};
 
tabs.get('tabC').postMessage('just for C');         // one specific tab
tabs.forEach((port) => port.postMessage('hi all')); // every tab

A shared worker, and its tabs, can use BroadcastChannel too; nothing stops them. But the ports are the reason a shared worker exists: they reach one shared instance that holds state or a connection. If all you need is tabs notifying each other, skip the shared worker and use BroadcastChannel on its own.

Service worker — a proxy in front of its pages

One worker, many client pages. Reply to the one that asked, or reach them all.

// sw.js
self.addEventListener('message', (e) => {
  e.source.postMessage('reply, just to the tab that asked');  // one client
});
 
// proactive, no tab has to ask first:
async function announce(msg) {
  const clients = await self.clients.matchAll({ type: 'window' });
  clients.forEach((c) => c.postMessage(msg));                 // every controlled tab
}