Cross-Tab and Cross-Browser Communication
A practical guide to communication between tabs, windows, iframes, and workers in one browser instance, plus communication across browser instances and devices.
Table of Contents
- Start With the Communication Boundary
- What the Browser Provides by Default
- BroadcastChannel
- The localStorage Storage Event
- window.postMessage and MessageChannel
- SharedWorker
- Service Worker as a Relay
- IndexedDB and Web Locks
- A Production Cross-Tab Event Bus
- Common Cross Tab Use Cases
- Leader Election and Single Owner Work
- Cross Browser Instances and Devices
- Server Mediated Real Time Architecture
- Reliability, Ordering and Replay
- Security and Privacy
- Decision Guide
- Testing and Observability
- Interview Questions and Answers
1. Start With the Communication Boundary
Before choosing an API, identify which contexts must communicate.
Same JavaScript context └─ components/modules in one page Related browsing contexts ├─ parent ↔ iframe ├─ opener ↔ popup └─ page ↔ dedicated worker Same origin + same browser instance ├─ Chrome Tab 1 ↔ Chrome Tab 2 ├─ Chrome Window 1 ↔ Chrome Window 2 ├─ SharedWorker clients └─ Service Worker clients Outside that browser instance ├─ Chrome instance ↔ Firefox instance ├─ browser ↔ desktop/mobile app └─ device A ↔ device B The most important distinction
For this guide, a browser instance means one running browser environment containing its tabs and windows. For example, Chrome Tab 1 and Chrome Tab 2 belong to the same Chrome instance.
The practical boundary is usually:
Same origin + same browser instance
Tabs inside that boundary can use browser-native communication. A different browser instance or device needs a shared external system—normally your backend.
Scope matrix
| Contexts | Direct browser communication? | Typical option |
|---|---|---|
| Components in one page | Yes | In-memory event bus or state store |
| Parent and known iframe/popup | Yes | postMessage |
| Chrome Tab 1 and Tab 2 on the same origin | Yes | BroadcastChannel, storage, SharedWorker |
| Two Chrome windows on the same origin in one browser instance | Yes | BroadcastChannel, storage, SharedWorker |
| Different origins | Restricted | postMessage for related windows; otherwise backend |
| Chrome instance and Firefox instance | No direct local channel | Backend |
| Two separate browser instances | No direct local channel | Backend |
| Different devices | No direct local channel | Backend |
Example: Chrome Tab 1 and Tab 2
Suppose both tabs are open in the same Chrome instance:
Tab 1: https://shop.example.com/products Tab 2: https://shop.example.com/cart Both URLs have the same origin:
protocol: https host: shop.example.com port: 443 (the default HTTPS port) Tab 1 can notify Tab 2 when an item is added to the cart.
Tab 1:
const channel = new BroadcastChannel('shop-events'); channel.postMessage({ type: 'cart:item-added', productId: 'P-100', }); Tab 2:
const channel = new BroadcastChannel('shop-events'); channel.addEventListener('message', event => { if (event.data.type === 'cart:item-added') { refreshCart(); } }); This works because both pages use the same origin and open a channel with the same name, shop-events.
It would not work directly if Tab 2 used https://admin.example.com, because that is a different origin.
Example: two Chrome windows
The same rule applies when the pages are in separate Chrome windows:
Chrome Window 1: https://shop.example.com/dashboard Chrome Window 2: https://shop.example.com/orders The pages can still use the same BroadcastChannel. Moving a tab into another Chrome window does not change its origin or prevent browser-managed communication.
For example, Window 1 can announce a logout:
const channel = new BroadcastChannel('auth-events'); channel.postMessage({ type: 'auth:logged-out' }); Window 2 listens for it:
const channel = new BroadcastChannel('auth-events'); channel.addEventListener('message', event => { if (event.data.type === 'auth:logged-out') { window.location.assign('/login'); } }); The important rule is not whether the pages are displayed as tabs or separate windows. They can communicate when they are in the same browser instance, use the same origin, and use the same channel name.
Back to Top
2. What the Browser Provides by Default
No library is required for the browser's core communication APIs.
| API | Scope | Message or shared state? | Durable? | Best use |
|---|---|---|---|---|
BroadcastChannel | Same origin in one browser instance | Message | No | General cross-tab events |
storage event | Same origin using localStorage | Change notification | Value persists | Compatibility fallback, simple signals |
window.postMessage | Related windows/iframes | Message | No | Popup/iframe communication |
MessageChannel | Two contexts with transferred ports | Message | No | Private point-to-point channel |
| SharedWorker | Same-origin tabs supported by the browser | Shared process/state + ports | Worker lifetime only | One socket or shared in-memory coordinator |
| Service Worker | Controlled pages for one origin | Message/relay | Worker is event-driven | Page coordination plus network/offline logic |
| IndexedDB | Same origin in one browser instance | Shared durable storage | Yes | Shared source of truth |
| Web Locks | Same origin in one browser instance | Coordination, not messaging | Lock lifetime only | Ensure one tab performs a task |
Browser support is part of the design
Support and lifecycle behavior vary, especially for SharedWorker, service workers, and background throttling. Build capability detection and choose an explicit fallback rather than assuming every API exists.
const capabilities = { broadcastChannel: 'BroadcastChannel' in globalThis, sharedWorker: 'SharedWorker' in globalThis, serviceWorker: 'serviceWorker' in navigator, webLocks: 'locks' in navigator, indexedDB: 'indexedDB' in globalThis, }; Back to Top
3. BroadcastChannel
BroadcastChannel is the simplest general-purpose API for same-origin cross-tab messaging.
Tab A ─┐ Tab B ─┼── channel: "app-events" Tab C ─┘ Basic example
const channel = new BroadcastChannel('app-events'); channel.addEventListener('message', event => { console.log('Received:', event.data); }); channel.postMessage({ type: 'cart:item-added', payload: { productId: 'P-100', quantity: 1 }, }); // When this tab no longer needs the channel: channel.close(); What it gives you
- One-to-many delivery to listening contexts
- Structured-clone payloads, not JSON-only strings
- No need to know which tabs exist
- No mutation of persistent browser storage
- Simple lifecycle and small API surface
What it does not give you
- Durable history or replay
- Delivery acknowledgement
- Guaranteed delivery to sleeping, frozen, or closed tabs
- Cross-origin communication
- Communication across separate browser instances
- A central owner for shared state or one shared WebSocket
Delivery semantics
Treat it as best-effort notification:
- Update the authoritative state.
- Broadcast that something changed.
- Receivers re-read or reconcile the source of truth.
For example, send cart:invalidated rather than assuming a full cart snapshot reached every tab.
Back to Top
4. The localStorage Storage Event
Changing localStorage in one tab produces a storage event in other same-origin tabs.
const EVENT_KEY = 'app:cross-tab-event'; window.addEventListener('storage', event => { if (event.key !== EVENT_KEY || event.newValue === null) return; const message = JSON.parse(event.newValue); console.log('Received:', message); }); function publish(message) { localStorage.setItem( EVENT_KEY, JSON.stringify({ ...message, id: crypto.randomUUID(), sentAt: Date.now(), }) ); localStorage.removeItem(EVENT_KEY); } Important behavior
- The tab that calls
setItemdoes not receive its ownstorageevent. - Values are strings, so serialization is required.
-
localStorageoperations are synchronous and can block the main thread. - Storage can be unavailable or restricted.
- Reusing an identical value may not produce the change behavior you expect; include a unique ID.
When to use it
- Compatibility fallback when
BroadcastChannelis unavailable - Low-frequency signals such as logout, theme changes, or cache invalidation
- Cases where the persisted value itself is useful
When not to use it
- High-frequency events
- Large payloads
- Streaming updates
- Secrets or authentication tokens
- A queue requiring reliable delivery
Back to Top
5. window.postMessage and MessageChannel
window.postMessage
Use postMessage when contexts have a direct relationship:
- Parent page and iframe
- Window and popup opened with
window.open - Embedded micro frontend and host shell
const popup = window.open('/payment', 'payment-window'); popup?.postMessage( { type: 'payment:start', orderId: 'O-100' }, 'https://app.example.com' ); Receiver:
window.addEventListener('message', event => { if (event.origin !== 'https://app.example.com') return; if (event.source !== window.opener) return; const message = event.data; if (message?.type === 'payment:start') { startPayment(message.orderId); } }); Never use '*' as targetOrigin for sensitive messages. Validate origin, source, message type, and payload.
MessageChannel
MessageChannel creates two connected ports. Transfer one port to another context to establish a private point-to-point channel.
const channel = new MessageChannel(); channel.port1.onmessage = event => { console.log('Reply:', event.data); }; iframe.contentWindow?.postMessage( { type: 'connect' }, 'https://app.example.com', [channel.port2] ); channel.port1.postMessage({ type: 'ping' }); This is useful when a host wants to give one embedded context a dedicated communication port instead of sharing all traffic on window.
Limitation
Unrelated tabs do not automatically have references to each other. Use BroadcastChannel, a worker relay, or the backend for discovery and communication between arbitrary tabs.
Back to Top
6. SharedWorker
A SharedWorker is one worker context shared by multiple same-origin tabs or windows.
Tab A ── MessagePort ─┐ Tab B ── MessagePort ─┼── SharedWorker ── one WebSocket ── Server Tab C ── MessagePort ─┘ Shared worker
// shared-connection-worker.js const ports = new Set(); let socket; function broadcast(message) { for (const port of ports) { port.postMessage(message); } } function ensureSocket() { if (socket && socket.readyState <= WebSocket.OPEN) return; socket = new WebSocket('wss://api.example.com/events'); socket.addEventListener('message', event => { broadcast({ type: 'server:event', payload: JSON.parse(event.data), }); }); } self.addEventListener('connect', event => { const [port] = event.ports; ports.add(port); port.start(); ensureSocket(); port.addEventListener('message', messageEvent => { if (messageEvent.data?.type === 'disconnect') { ports.delete(port); port.close(); } }); }); Tab
const worker = new SharedWorker('/shared-connection-worker.js', { name: 'app-connection', }); worker.port.start(); worker.port.addEventListener('message', event => { handleServerEvent(event.data); }); window.addEventListener('pagehide', () => { worker.port.postMessage({ type: 'disconnect' }); worker.port.close(); }); Best uses
- One WebSocket shared by all tabs
- Shared in-memory state
- Request deduplication
- One centralized coordinator
- Cross-tab work distribution
Trade-offs
- Support and lifecycle behavior must be verified for target browsers.
- The worker can terminate when no clients remain.
- It is not a durable data store.
- Authentication refresh and reconnect logic still need careful design.
- A broken worker can affect every connected tab.
Back to Top
7. Service Worker as a Relay
A service worker can find controlled windows and relay messages.
Service worker
self.addEventListener('message', async event => { const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: false, }); for (const client of clients) { if (client.id === event.source?.id) continue; client.postMessage(event.data); } }); Page
navigator.serviceWorker.addEventListener('message', event => { handleCrossTabMessage(event.data); }); async function publish(message) { const registration = await navigator.serviceWorker.ready; registration.active?.postMessage(message); } Best uses
- Coordination already tied to offline/network behavior
- Push notification fan-out to open pages
- Cache invalidation after background sync
- Messaging between controlled pages and the service worker
Important lifecycle rule
A service worker is event-driven and can be stopped between events. Do not treat its global variables as durable shared state. Store durable state in IndexedDB and use messages as signals.
Newly opened pages may not be controlled until navigation or clients.claim() is used during activation.
Back to Top
8. IndexedDB and Web Locks
These APIs solve coordination problems rather than replacing a message bus.
IndexedDB as shared durable state
All same-origin tabs in the same browser instance can read the same IndexedDB database.
Good pattern:
Tab A writes durable record to IndexedDB -> Tab A broadcasts { type: "record:changed", id: "R-100" } -> Tab B receives the signal -> Tab B reads the current record from IndexedDB This avoids sending large payloads and lets a tab recover after missing a transient message.
Handle versionchange so old tabs close stale database connections during schema upgrades:
database.addEventListener('versionchange', () => { database.close(); showReloadRequired(); }); Web Locks for single-owner work
The Web Locks API ensures that only one same-origin context holds a named lock at a time.
async function tryBecomeSyncLeader(signal) { return navigator.locks.request( 'background-sync-leader', { ifAvailable: true, signal }, async lock => { if (!lock) return false; await runSyncLoop(signal); return true; } ); } The lock remains held until the callback's promise settles.
Use Web Locks for:
- One tab performs polling
- One tab refreshes a shared cache
- One tab processes an IndexedDB queue
- Avoiding concurrent migrations
Web Locks do not send results to other tabs. Pair them with BroadcastChannel or shared storage.
Back to Top
9. A Production Cross-Tab Event Bus
A wrapper gives the application one contract with a BroadcastChannel primary path and storage fallback.
type EventMap = { 'auth:logged-out': { reason: string }; 'cart:invalidated': { cartId: string }; 'theme:changed': { theme: 'light' | 'dark' }; }; type Envelope<K extends keyof EventMap = keyof EventMap> = { id: string; sourceId: string; type: K; version: 1; sentAt: number; payload: EventMap[K]; }; class CrossTabBus { #sourceId = crypto.randomUUID(); #storageKey: string; #channel?: BroadcastChannel; #handlers = new Map<keyof EventMap, Set<(payload: never) => void>>(); constructor(name: string) { this.#storageKey = `cross-tab:${name}`; if ('BroadcastChannel' in globalThis) { this.#channel = new BroadcastChannel(name); this.#channel.addEventListener('message', event => { this.#receive(event.data); }); } else { window.addEventListener('storage', this.#onStorage); } } publish<K extends keyof EventMap>(type: K, payload: EventMap[K]) { const envelope: Envelope<K> = { id: crypto.randomUUID(), sourceId: this.#sourceId, type, version: 1, sentAt: Date.now(), payload, }; // BroadcastChannel and storage events do not deliver to the sender. this.#receive(envelope); if (this.#channel) { this.#channel.postMessage(envelope); return; } localStorage.setItem(this.#storageKey, JSON.stringify(envelope)); localStorage.removeItem(this.#storageKey); } subscribe<K extends keyof EventMap>( type: K, handler: (payload: EventMap[Source:https://dev.to/zeeshanali0704/cross-tab-and-cross-browser-communication-2b99