Who can hear this? A map of browser messaging
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:
- Who actually receives this?
- 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 ClientThe 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
| Mechanism | Connects | Who hears it |
|---|---|---|
window.postMessage | a window to another window / iframe / popup | broadcast: every message listener in the receiving window, third-party scripts included |
worker.postMessage (dedicated) | a page and its own worker | private 1:1, those two only |
SharedWorker.port | many same-origin tabs to one worker | each tab gets its own port; the worker can target one or loop |
service worker client.postMessage | a service worker to its pages | broadcast to the clients it controls |
MessageChannel port | any two contexts you wire together | private 1:1, only whoever holds the port |
BroadcastChannel | every same-origin context on a named channel | broadcast 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:
| postMessage | MessageChannel | BroadcastChannel | |
|---|---|---|---|
| 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 → openerIt 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 tabA 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
}