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

  1. Start With the Communication Boundary
  2. What the Browser Provides by Default
  3. BroadcastChannel
  4. The localStorage Storage Event
  5. window.postMessage and MessageChannel
  6. SharedWorker
  7. Service Worker as a Relay
  8. IndexedDB and Web Locks
  9. A Production Cross-Tab Event Bus
  10. Common Cross Tab Use Cases
  11. Leader Election and Single Owner Work
  12. Cross Browser Instances and Devices
  13. Server Mediated Real Time Architecture
  14. Reliability, Ordering and Replay
  15. Security and Privacy
  16. Decision Guide
  17. Testing and Observability
  18. 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:

  1. Update the authoritative state.
  2. Broadcast that something changed.
  3. 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 setItem does not receive its own storage event.
  • Values are strings, so serialization is required.
  • localStorage operations 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 BroadcastChannel is 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

Related posts