DEV Community

ZeeshanAli-0704
ZeeshanAli-0704

Posted on

Cross-Tab and Cross-Browser Communication

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.

The problem in one minute

You open a shopping app in two tabs. In Tab A, you add a book to your cart. Tab B still shows Cart (0).

Why? Each tab runs its own application code and holds its own in-memory state. Updating a variable or a React store in Tab A does not update the corresponding variable or store in Tab B. Even if both tabs share a login cookie, their visible UI state is separate.

Cross-tab communication lets Tab A tell Tab B: "The cart changed; check the latest cart."

Now open the same app in Firefox or on your phone. A Chrome-local channel cannot reach those clients. The backend must store the change and notify the other connected clients.

That is the central idea of this article:

Messages tell other contexts that something happened. An authoritative store tells them what is true now.

We will use a shopping app as a running example: cart updates, logout, one shared connection, and recovery after a tab misses an update.

How to read the diagrams

The diagrams below use Mermaid, which GitHub can render directly. If your Markdown viewer does not support Mermaid, the surrounding explanations describe the same flow.

flowchart LR
    A["Tab A: add a book"] --> S["Backend: save cart"]
    S --> R["Tab A: save confirmed"]
    R --> C["Browser-local notification"]
    C --> B["Tab B: fetch latest cart"]
    S --> E["Backend real-time delivery"]
    E --> P["Phone or Firefox: fetch latest cart"]

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

Additional references: Summary | Further Reading


1. Start With the Communication Boundary

Before choosing an API, identify which contexts must communicate.

What is a context, and why are tabs isolated?

A context is a place where JavaScript runs: a page, an iframe, or a worker. A worker runs JavaScript without owning a page's DOM.

Two tabs loading the same app do not share ordinary JavaScript objects:

// Run this in Tab A's console.
window.cartCount = 3;

// Run this in Tab B's console.
console.log(window.cartCount); // undefined, unless Tab B set its own value
Enter fullscreen mode Exit fullscreen mode

This isolation lets pages run independently and helps enforce browser security boundaries. A message API is an explicit bridge between contexts; it does not merge their memory.

Think of tabs as separate rooms:

  • An in-memory store is a whiteboard inside one room.
  • BroadcastChannel is a live announcement to rooms on the same local channel.
  • IndexedDB is a shared filing cabinet those rooms can read.
  • A backend is the shared service that people in other buildings can also reach.
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
Enter fullscreen mode Exit fullscreen mode

The most important distinction

For this guide, a browser instance is shorthand for a browser environment containing tabs and windows that can share the relevant browser-managed storage and messaging scope. It does not mean one operating-system process: modern browsers use many processes.

For ordinary first-party tabs, start with:

Same origin + same browser instance

The more precise rule is same origin + compatible storage partition/profile + API-specific scope. Separate profiles, normal versus private browsing, and browser privacy partitioning can separate otherwise identical URLs. A same-origin iframe embedded under a different top-level site may be in a different storage partition and unable to join the same BroadcastChannel.

Conversely, opening a second browser window is not necessarily a new isolated environment. Two windows using the same profile can still communicate. Do not use window count or process IDs to determine the boundary.

Separate browser environments or devices normally need a shared external system: your backend. The tables below assume ordinary same-profile, first-party pages; API-specific restrictions still apply.

What exactly is an origin?

An origin is the combination of scheme + hostname + port. A URL path is not part of it.

URL compared with https://shop.example.com/cart Same origin? Why?
https://shop.example.com/products Yes Only the path changed
https://shop.example.com/cart?view=small Yes Query parameters do not change the origin
https://admin.example.com/cart No Different hostname
http://shop.example.com/cart No Different scheme
https://shop.example.com:8443/cart No Different port

Why this matters: the browser uses origins as security boundaries. Two pages belonging to the same business are not automatically allowed to share a local channel.

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
Enter fullscreen mode Exit fullscreen mode

Both URLs have the same origin:

protocol: https
host:     shop.example.com
port:     443 (the default HTTPS port)
Enter fullscreen mode Exit fullscreen mode

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',
});
Enter fullscreen mode Exit fullscreen mode

Tab 2:

const channel = new BroadcastChannel('shop-events');

channel.addEventListener('message', event => {
  if (event.data.type === 'cart:item-added') {
    refreshCart();
  }
});
Enter fullscreen mode Exit fullscreen mode

Assuming both tabs use the same profile and storage partition, 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
Enter fullscreen mode Exit fullscreen mode

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' });
Enter fullscreen mode Exit fullscreen mode

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');
  }
});
Enter fullscreen mode Exit fullscreen mode

The important rule is not whether the pages are displayed as tabs or separate windows. For BroadcastChannel, they need the same origin, compatible storage partition, and 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.

Three different jobs: notify, store, coordinate

These jobs often appear together, but they are not interchangeable:

Job Plain-language question Shopping-app example Suitable mechanism
Communication "What should another tab know?" "Cart C-900 changed" BroadcastChannel
Storage "Where can I read the current data?" Latest saved cart or offline draft Backend or IndexedDB
Coordination "Who is allowed to do this work right now?" Only one tab drains the offline queue Web Locks

A message is like a doorbell: it gets someone's attention. Stored state is the package left at the door: it can still be read later. A lock is the key that lets one person handle the package at a time.

For example, BroadcastChannel can announce a cart change, but it does not store the cart or prevent two tabs from submitting the same operation.

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.

Service workers and Web Locks require secure contexts, normally HTTPS; localhost is generally treated as trustworthy for development. Storage APIs can also fail because of browser policies or quota limits. Detecting an API's presence is a first check, not proof that every operation will succeed.

const capabilities = {
  broadcastChannel: 'BroadcastChannel' in globalThis,
  sharedWorker: 'SharedWorker' in globalThis,
  serviceWorker: 'serviceWorker' in navigator,
  webLocks: 'locks' in navigator,
  indexedDB: 'indexedDB' in globalThis,
};
Enter fullscreen mode Exit fullscreen mode

Back to Top


3. BroadcastChannel

BroadcastChannel is the simplest general-purpose API for same-origin cross-tab messaging.

What it is, and why use it?

It is a browser-managed publish/subscribe channel:

  • A publisher sends an event to a named channel.
  • Subscribers create a channel object with the same name and listen.
  • The publisher does not need a reference to each subscriber.

Use it when independent tabs need small, live notifications. Unlike polling, receivers do not repeatedly ask, "Did something change?"

The channel name is a routing label, not a password. Anyone running code in the same eligible origin/partition can open that channel.

Tab A ─┐
Tab B ─┼── channel: "app-events"
Tab C ─┘
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

Messages are delivered asynchronously. postMessage() returning means the send operation did not throw; it is not an acknowledgement that another tab received or applied the event.

The sending channel object does not receive its own message. Other eligible channel objects can receive it, including a second channel object in the same page. Update the sender's UI directly, or deliberately provide local delivery in a wrapper.

Structured clone means the browser copies supported values, such as plain objects, arrays, and dates. It does not send a live reference to the sender's object. Functions and DOM elements cannot be sent this way, and changing your original object afterward does not change the receiver's copy.

Try it: two tabs, no backend required

Open two ordinary tabs at the same application origin, such as your local development server. Run this in the DevTools console of both tabs:

const demoChannel = new BroadcastChannel('cart-learning-demo');

demoChannel.onmessage = event => {
  console.log('Another tab announced:', event.data);
};
Enter fullscreen mode Exit fullscreen mode

Then run this in Tab A:

demoChannel.postMessage({
  type: 'cart:invalidated',
  cartId: 'C-900',
});
Enter fullscreen mode Exit fullscreen mode

Tab B logs the object; Tab A's sending object does not. Open Tab C afterward and run the listener setup: it will not receive that earlier message, because the channel has no history. Send again to reach both B and C.

When finished, run demoChannel.close() in each tab. This experiment demonstrates delivery, not real cart storage.

Better application example: save first, notify second

Suppose the cart contains two books. Tab A adds a third book:

sequenceDiagram
    actor User
    participant A as Tab A - products
    participant API as Cart API
    participant B as Tab B - cart
    User->>A: Add one book
    A->>API: POST cart item
    API-->>A: Saved cart, revision 17
    A->>A: Render confirmed cart
    A-->>B: Broadcast cart:invalidated
    B->>API: GET current cart
    API-->>B: Cart with three books, revision 17
    B->>B: Render current cart

The important ordering is commit, then notify. Broadcasting before the save completes could make Tab B fetch the old cart. If the save fails, show an error in Tab A rather than broadcasting a successful change.

Why announce an invalidation instead of saying "increase your count by one"? Tab B might have missed an earlier increment, or another device might have edited the cart. Reading the current cart repairs both problems.

On initial load and when a tab becomes active again, also refresh or check the cart revision. Recovery must not depend on receiving every broadcast.

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.

What it is, and why use it?

localStorage is a small persistent string key/value store. The storage event tells other eligible pages that a value changed; it was not originally designed as a message broker.

Imagine writing a note on a shared noticeboard. Other rooms are notified about the edit, but the room making the edit already knows what it wrote.

This makes it useful for a low-frequency fallback, such as "theme changed to dark." It is a poor fit for mouse movements or hundreds of live updates per second because writing storage is synchronous.

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);
}
Enter fullscreen mode Exit fullscreen mode

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.

In the example, setItem creates a change carrying the message, and removeItem removes the temporary value. Receivers ignore the removal event because its newValue is null. The unique ID makes each written value different.

Removing the value does not turn this into a durable queue. A tab opened later cannot retrieve the old signal. For an actual preference, store theme = dark separately so new tabs can read the current preference on startup.

The snippet assumes valid JSON and available storage to keep the mechanism visible. Application code must catch malformed JSON and storage failures at the transport boundary, report the error, and use an explicit recovery path. Also check event.storageArea === localStorage when the listener must exclude sessionStorage events.

sessionStorage is not an arbitrary cross-tab substitute: it is scoped to a top-level browsing context, with sharing limited to eligible frames within that context.

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

What: a way to send a message to a window you already have a reference to.

Why: a host page and a payment iframe may intentionally use different origins. They cannot share a BroadcastChannel, but can exchange a narrow, validated message contract through postMessage.

Unlike a broadcast, this is addressed communication: "send this to that popup," not "tell every tab listening on this channel."

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'
);
Enter fullscreen mode Exit fullscreen mode

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);
  }
});
Enter fullscreen mode Exit fullscreen mode

Never use '*' as targetOrigin for sensitive messages. Validate origin, source, message type, and payload.

The popup example shows message routing, not a complete startup protocol. Sending immediately after window.open() can race with navigation and listener installation. In a real integration, the popup announces payment:ready; the opener validates that message's origin and source before sending payment:start.

sequenceDiagram
    participant H as Shop page
    participant P as Payment popup
    H->>P: Open popup
    P->>P: Install message listener
    P-->>H: payment:ready
    H->>H: Validate origin and source
    H->>P: payment:start with exact targetOrigin
    P->>P: Validate origin, source and payload
    P-->>H: payment:completed with order reference
    H->>H: Verify payment through backend

Why both checks? origin checks the sender's site; source checks which window sent it. A message from an unexpected window on an otherwise allowed site should not control this popup flow.

Treat payment:completed as a hint to check the order, not proof that money moved. The backend must verify payment status.

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' });
Enter fullscreen mode Exit fullscreen mode

This is useful when a host wants to give one embedded context a dedicated communication port instead of sharing all traffic on window.

Think of the initial postMessage as introducing two people, and the transferred port as giving them a dedicated phone line. The iframe must accept the initial connection only after validating the sender, then attach a listener to event.ports[0].

When using addEventListener('message', ...) on a MessagePort, call port.start() to begin delivery. Assigning port.onmessage starts it automatically. Close the ports when the integration ends.

The port does not replace authentication: it is a capability handed to another context. Transfer it only to the intended receiver.

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.

What problem does a shared worker solve?

With three independent tabs, your app may accidentally open three WebSockets and perform the same processing three times. That can be acceptable, but it costs connections and duplicates work.

A SharedWorker moves that work to a shared JavaScript runtime. Each tab connects through a MessagePort; the worker owns one socket and distributes incoming updates.

Without a SharedWorker With a SharedWorker
Each tab owns a socket The worker owns the socket
Each tab handles reconnects The worker centralizes reconnect handling
In-memory state is separate Worker-owned state is shared through messages
Closing a tab closes its socket Closing one tab need not disconnect other clients

Eligible clients must agree on the worker script URL/name and compatible worker options, and be within the browser's sharing scope. Two unrelated worker scripts do not become one just because they serve the same app.

Shopping-app example: all tabs display order-status updates. The worker receives one order.shipped event and forwards it to each tab. Each tab decides how to render its own UI; the worker cannot directly update the DOM.

Tab A ── MessagePort ─┐
Tab B ── MessagePort ─┼── SharedWorker ── one WebSocket ── Server
Tab C ── MessagePort ─┘
Enter fullscreen mode Exit fullscreen mode

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();
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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.

The code above is a teaching skeleton. It does not reconnect a closed socket automatically, validate server messages, or reliably detect crashed clients. A pagehide disconnect is best-effort and a page restored from the back/forward cache needs to reconnect its port. Production ownership must account for these lifecycle cases.

Back to Top


7. Service Worker as a Relay

A service worker can find controlled windows and relay messages.

Why use this instead of a SharedWorker?

A service worker is primarily an event-driven network/offline intermediary, not an always-running shared process.

For example, the shopping app's background sync finishes uploading an offline cart operation. The service worker can notify controlled pages: "The cart cache changed." Those pages read the updated cache or server state.

Use this relay when your application already has service-worker responsibilities. Introducing a service worker only to send a simple theme event usually adds unnecessary lifecycle complexity.

Service worker

self.addEventListener('message', event => {
  event.waitUntil((async () => {
    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);
    }
  })());
});
Enter fullscreen mode Exit fullscreen mode

Page

navigator.serviceWorker.addEventListener('message', event => {
  handleCrossTabMessage(event.data);
});

async function publish(message) {
  const registration = await navigator.serviceWorker.ready;
  registration.active?.postMessage(message);
}
Enter fullscreen mode Exit fullscreen mode

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.

event.waitUntil() ties the relay's asynchronous work to the message event's lifetime. Merely making the listener async does not ask the browser to keep the service worker alive for that promise.

The page example assumes successful registration and activation. navigator.serviceWorker.ready can remain pending when no active registration is available, so complete setup and expose registration errors before offering worker-dependent features.

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.

What: an asynchronous browser database with records, indexes, and transactions.

Why: a message disappears after delivery; a saved draft should still exist when another tab opens later. IndexedDB lets that new tab read the current saved record without having heard the earlier event.

Here, "durable" means data outlives an individual page or worker, not that it is indestructible. Users can clear storage, browsers may evict it, and private browsing has different retention rules. Use the server for data that must survive loss of local storage.

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
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

Web Locks for single-owner work

The Web Locks API ensures that only one same-origin context holds a named lock at a time.

What: a browser-managed way for cooperating contexts to request exclusive access to named work.

Why: two tabs can both notice an offline queue is ready. Broadcasting "I am processing it" is not a lock: both tabs could announce that at the same time. An exclusive Web Lock serializes the cooperating owners.

async function tryBecomeSyncLeader(signal) {
  return navigator.locks.request(
    'background-sync-leader',
    { ifAvailable: true },
    async lock => {
      if (!lock) return false;

      await runSyncLoop(signal);
      return true;
    }
  );
}
Enter fullscreen mode Exit fullscreen mode

The lock remains held until the callback's promise settles.

Here, ifAvailable: true means "try now; do not queue if another tab holds it." The callback receives null when it cannot acquire the lock. The signal is passed to the application-defined runSyncLoop to stop that loop cooperatively; it is not combined with ifAvailable in the lock options, because that combination is invalid.

If another tab needs to take over later, it must request the lock again, or use a queued request without ifAvailable. There is no automatic retry in this helper. Without ifAvailable, a lock-request signal can cancel a pending request; it does not automatically stop a callback that already acquired the lock.

sequenceDiagram
    participant A as Tab A
    participant L as Web Locks
    participant B as Tab B
    A->>L: Try background-sync-leader
    L-->>A: Lock granted
    B->>L: Try same lock with ifAvailable
    L-->>B: Not available - callback gets null
    A->>A: Process queue until stopped
    A->>L: Callback settles - release lock
    B->>L: Try again later
    L-->>B: Lock granted

Locks protect only cooperating clients in the applicable browser scope. They do not prevent a phone, another browser, or a direct API caller from submitting the same operation. Server-side idempotency is still needed for operations such as creating an order.

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.

Why add a wrapper?

Without one, every feature repeats transport selection, event names, subscription cleanup, and serialization. A shared bus lets feature code express intent:

Feature code: "publish cart:invalidated"
                 |
                 v
Bus: envelope + local delivery + chosen transport
                 |
                 v
Receiver: validate + dispatch to the cart subscriber
Enter fullscreen mode Exit fullscreen mode

An envelope is metadata wrapped around the business payload. Think of a postal envelope: the event type says what it is, the ID identifies the delivery, and the schema version tells the receiver how to interpret its contents.

The implementation below is a production-oriented starting point, not a complete hardened bus. In particular, TypeScript types do not validate incoming data at runtime, and the basic envelope check below does not validate the allowed event types or their payloads. Apply the production additions before treating messages as trusted application events.

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[K]) => void
  ) {
    const handlers = this.#handlers.get(type) ?? new Set();
    handlers.add(handler as (payload: never) => void);
    this.#handlers.set(type, handlers);

    return () => handlers.delete(handler as (payload: never) => void);
  }

  close() {
    this.#channel?.close();
    window.removeEventListener('storage', this.#onStorage);
    this.#handlers.clear();
  }

  #onStorage = (event: StorageEvent) => {
    if (event.key !== this.#storageKey || event.newValue === null) return;
    this.#receive(JSON.parse(event.newValue));
  };

  #receive(envelope: Envelope) {
    if (!this.#isValid(envelope)) return;

    const handlers = this.#handlers.get(envelope.type);
    for (const handler of handlers ?? []) {
      handler(envelope.payload as never);
    }
  }

  #isValid(value: unknown): value is Envelope {
    if (typeof value !== 'object' || value === null) return false;
    const candidate = value as Partial<Envelope>;

    return (
      typeof candidate.id === 'string' &&
      typeof candidate.sourceId === 'string' &&
      typeof candidate.type === 'string' &&
      candidate.version === 1 &&
      typeof candidate.sentAt === 'number'
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Production additions

  • Catch and report JSON parsing, serialization, channel creation, and storage errors; define how the UI recovers when messaging is unavailable.
  • Deduplicate recent message IDs.
  • Expire messages older than an allowed clock-skew window.
  • Validate each payload with a runtime schema.
  • Add event-version migration rules.
  • Record publish/receive/drop metrics.
  • Do not send secrets.
  • Use invalidation events when the payload can be re-read.

The wrapper deliberately delivers to subscribers in the publishing page too. Keep feature handlers idempotent and avoid handling the same local action both directly and through the subscription.

A fallback also needs a deployment-wide transport policy. A tab using only BroadcastChannel cannot hear a tab using only storage events. If mixed modes are possible, design and test a bridge or a common transport; dual publishing requires ID-based deduplication and loop prevention.

Back to Top


10. Common Cross-Tab Use Cases

10.1 Logout across tabs in one browser instance

bus.subscribe('auth:logged-out', () => {
  clearInMemoryAuthState();
  router.navigate('/login');
});

async function logout() {
  await api.logout();
  bus.publish('auth:logged-out', { reason: 'user-requested' });
}
Enter fullscreen mode Exit fullscreen mode

The server must still invalidate the session. A cross-tab event improves UX but is not the security boundary.

The bus in Section 9 also invokes the sender's subscribers, so the logout handler clears and navigates the initiating tab as well. Doing that again in logout() would duplicate the local side effects. Surface a failed logout request explicitly rather than publishing a server-confirmed logout event.

10.2 Theme and preference sync

Persist the preference, then broadcast its new version.

function setTheme(theme) {
  localStorage.setItem('theme', theme);
  bus.publish('theme:changed', { theme });
}
Enter fullscreen mode Exit fullscreen mode

10.3 Cache invalidation

bus.subscribe('cart:invalidated', ({ cartId }) => {
  queryClient.invalidateQueries({ queryKey: ['cart', cartId] });
});
Enter fullscreen mode Exit fullscreen mode

Invalidation is safer than treating transient messages as the only copy of state.

10.4 Prevent duplicate notifications

Elect one owner to show system notifications while every tab can still update its visible UI.

10.5 Draft coordination

Store drafts in IndexedDB and broadcast document version changes. If two tabs edit simultaneously, use version checks, conflict resolution, or a collaborative protocol rather than last-write-wins by accident.

10.6 Single background poller

One tab holds a Web Lock and polls the server. It writes results to IndexedDB and broadcasts invalidations to other tabs.

10.7 Putting the shopping-app flow together

Situation What to do Why
User opens the cart in a new tab Fetch the current cart Earlier broadcasts are not replayed
Tab A successfully adds an item Render the response, then broadcast an invalidation Other tabs should read committed state
Tab B receives the invalidation Re-fetch its active cart query An increment alone cannot repair missed changes
Tab B wakes after being suspended Revalidate on resume or visibility change It may have missed notifications
Phone edits the same cart Backend notifies authorized browser connections A local channel cannot reach the phone
Two tabs retry the same checkout Backend deduplicates with an operation/idempotency key Local coordination is not a payment guarantee

If server events are also relayed locally, choose who owns the connection and deduplicate deliveries. Otherwise Tab B might receive the same change from both its own socket and Tab A's broadcast.

Back to Top


11. Leader Election and Single-Owner Work

Leader election is useful when only one tab should:

  • Poll the server
  • Maintain a connection
  • Process an offline queue
  • Display a system notification
  • Refresh shared data

What does "leader" mean here?

A leader is simply the context temporarily responsible for one task. It is not a special browser permission and does not need to own every part of the application.

For example, with four open tabs and a five-second polling interval, independent polling produces roughly 48 requests per minute. One polling leader produces roughly 12, then shares the results locally. This saves work but introduces a new requirement: another tab must take over when the leader disappears.

Three concepts help explain that requirement:

  • Heartbeat: a periodic "I am still here" signal. A missing heartbeat is suspicion, not proof that a tab has stopped.
  • Lease: ownership that expires unless renewed.
  • Fencing token: an increasing ownership number that lets the server reject a former owner's late writes.

Background throttling can delay a healthy tab. That is why a timer alone is not enough to establish exclusive ownership for critical work.

Prefer a platform coordinator

Use, in order:

  1. SharedWorker when one shared runtime/connection is the design
  2. Web Locks when one tab should temporarily own a task
  3. A backend lease when ownership must span browser instances or devices

Why a localStorage lease is risky

A naive lease uses read-modify-write operations:

Tab A reads "no leader"
Tab B reads "no leader"
Tab A writes "A is leader"
Tab B writes "B is leader"
Both believe they won
Enter fullscreen mode Exit fullscreen mode

localStorage does not provide a compare-and-swap transaction. A timestamp lease can reduce collisions but cannot provide strong distributed-lock guarantees.

Leases need fencing

For critical server-side work, issue a monotonically increasing fencing token:

leader A gets token 41
leader A pauses
leader B gets token 42
server rejects later writes carrying stale token 41
Enter fullscreen mode Exit fullscreen mode

Do not rely on a browser tab as the exclusive owner of financially or operationally critical work. Put correctness on the server.

Back to Top


12. Cross-Browser Instances and Devices

What “cross-browser” can mean

Scenario Browser-local APIs work?
Chrome Tab 1 and Tab 2 in the same Chrome instance Yes
Two Chrome windows in the same Chrome instance Yes
Chrome instance and Firefox instance No
Two separate browser instances No
Browser and mobile app No
Laptop and phone No

Browser-local APIs connect tabs and windows inside one browser instance. They do not create a direct channel to another browser instance. For example, a Chrome instance cannot open a BroadcastChannel directly to a Firefox instance.

Why doesn't using the same channel name work?

The channel name is looked up by the local browser within its own eligible scope. It is not a globally registered network address, and no message is automatically uploaded to your server.

Opening new BroadcastChannel('cart-events') in Chrome and Firefox is like tuning two disconnected intercom systems to "cart-events": the label matches, but there is no wire between the systems.

Likewise, signing into the same account does not automatically synchronize localStorage or IndexedDB. Your application must explicitly synchronize user data through its backend.

The shared meeting point

Use infrastructure both clients can reach:

Chrome tab ──┐
Firefox tab ─┼── HTTPS / WebSocket / SSE ── Backend ── shared state/event broker
Mobile app ──┘
Enter fullscreen mode Exit fullscreen mode

Possible transports:

  • WebSocket for bidirectional real-time messaging
  • SSE for server-to-client streams
  • Short or long polling as fallback
  • Push notifications for background delivery
  • WebRTC for peer-to-peer data after server-based signaling

WebRTC does not remove the need for discovery, authentication, signaling, and usually a relay fallback.

Choose the transport by the direction of information

Transport Simple mental model Example Main trade-off
WebSocket An open two-way conversation Collaborative cart or chat Connection management, reconnects, and server capacity
SSE A live server announcement feed Order-status updates Server-to-client only; client writes use normal HTTP
Polling Ask "anything new?" on a schedule Refresh cart every 30 seconds Simpler, but stale between requests and wasteful when nothing changes
Web Push A background notification via a push service Notify that an order shipped Permissions and delivery constraints; not a live state-sync guarantee

For a cart app, normal HTTP writes plus SSE notifications may be enough. A persistent bidirectional socket is useful only if both directions need it; "real-time" does not automatically mean WebSocket.

Back to Top


13. Server-Mediated Real-Time Architecture

Reference architecture

Browser instance/device A
        |
        | WebSocket + authenticated session
        v
Connection Gateway
        |
        +── Presence/connection registry
        |
        +── Event broker ── durable event store
        |
        +── Authorization service
        |
        v
Connection Gateway
        |
        v
Browser instance/device B
Enter fullscreen mode Exit fullscreen mode

Connection identity

The backend cannot route an event merely because two clients are connected. It needs to know who each client represents and which resources that identity may access.

Model at least:

  • userId
  • sessionId
  • deviceId
  • connectionId
  • Optional tabId

One user can have many active connections. Route events to the intended scope:

user scope     -> every session/device for the user
session scope  -> tabs sharing one login session
device scope   -> one device
connection     -> one socket/tab
Enter fullscreen mode Exit fullscreen mode

Example event

{
  "id": "evt_01J...",
  "type": "cart.updated",
  "version": 2,
  "sequence": 1842,
  "occurredAt": "2026-10-17T10:00:00Z",
  "scope": {
    "userId": "U-100"
  },
  "payload": {
    "cartId": "C-900",
    "revision": 17
  }
}
Enter fullscreen mode Exit fullscreen mode

Client flow

  1. Authenticate the connection.
  2. Subscribe only to authorized scopes.
  3. Receive an event with ID, version, and sequence.
  4. Deduplicate by event ID.
  5. Detect sequence gaps.
  6. Re-fetch authoritative state or request replay.
  7. Apply the update.
  8. Persist the last processed cursor if required.

Concrete example: phone updates the cart, laptop follows

  1. The phone sends an authenticated cart-update request.
  2. The backend verifies ownership and commits cart revision 18.
  3. The event system publishes cart.updated for that user's authorized connections.
  4. The laptop receives the event and notices that its local cart is revision 17.
  5. The laptop fetches revision 18 and refreshes the UI.

If the laptop is disconnected at step 3, the backend still has revision 18. Reconnecting and fetching the current cart restores correctness even without event replay.

An event broker fans events out between backend services or connection gateways. A durable event store retains events for later replay. These may be separate systems: using a broker does not automatically mean you have replayable history.

Also consider the gap between saving state and publishing its event. If the server crashes after the cart commit but before publishing, a notification can be lost. A transactional outbox records the state change and a pending event in the same database transaction; a separate publisher retries delivery. Consumers still need deduplication because retries can create duplicate notifications.

Back to Top


14. Reliability, Ordering and Replay

Local browser channels

BroadcastChannel, storage, and worker messages are useful notifications, but do not treat them as a durable event log.

Tabs can:

  • Open after an event was sent
  • Sleep or freeze
  • Crash
  • Miss a transient message
  • Process events at different times

Reliable-state pattern

Authoritative state: server or IndexedDB
Transient message: "state changed"
Recovery: read current version/snapshot
Enter fullscreen mode Exit fullscreen mode

What can go wrong? A small timeline

sequenceDiagram
    participant A as Tab A
    participant API as Backend
    participant B as Tab B
    B->>B: Suspended with cart revision 16
    A->>API: Save revision 17
    API-->>A: Saved
    A--xB: Notification not observed while suspended
    B->>B: Resume later
    B->>API: Fetch current cart
    API-->>B: Revision 17
    B->>B: Replace stale view

The fix is not to make Tab A keep broadcasting forever. The fix is to make recovery read the source of truth.

Translate reliability terms into application behavior

Term Meaning Example response
Acknowledgement Receiver confirms a particular stage completed Distinguish "received" from "applied"
Retry Send or perform an operation again Reconnect and retry a failed fetch
Duplicate The same event arrives more than once Ignore a recently processed event ID
Idempotent handler Repeating the handler does not create extra effects Set cart revision 18 instead of blindly incrementing count
Ordering The sequence in which changes are interpreted Do not replace revision 18 with revision 17
Replay Read retained events after a saved position Resume after cursor 1842
Snapshot A complete current view of state Fetch the current cart when replay is unavailable

Acknowledgements and retries alone do not produce "exactly once" business effects. A receiver can apply an operation, crash before acknowledging, and then receive the retry. Protect the business operation with idempotency or transactional deduplication.

Event envelope

Include:

  • Unique message ID
  • Source context ID
  • Event type
  • Schema version
  • Timestamp
  • Entity revision or sequence
  • Payload

Ordering

Do not assume unrelated publishers create one global order.

For example, a slow request for cart revision 17 can finish after a faster request for revision 18. Compare revisions before applying a response, or use your data-fetching layer's cancellation/stale-result controls. Receiving the newer event first does not prevent an older HTTP response from overwriting the UI.

Use:

  • Per-entity revisions
  • Per-stream sequence numbers
  • Server-assigned order where required
  • Conflict-resolution rules

Deduplication

Maintain a bounded set of recent event IDs:

class RecentIds {
  #ids = new Map();

  constructor(maxSize = 1_000) {
    this.maxSize = maxSize;
  }

  has(id) {
    return this.#ids.has(id);
  }

  add(id) {
    this.#ids.set(id, true);
    while (this.#ids.size > this.maxSize) {
      this.#ids.delete(this.#ids.keys().next().value);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Reconnection

After reconnect:

  1. Send the last processed cursor.
  2. Replay missed events when supported.
  3. If replay is unavailable or the gap is too large, fetch a fresh snapshot.
  4. Resume live events from the new cursor.

Replay and live delivery must have a defined handoff. Otherwise an event can occur between "fetch snapshot" and "start listening" and fall into the gap. A server can return a snapshot with a cursor and replay events after that cursor, or the client can subscribe first and buffer events while fetching a versioned snapshot.

Sequence numbers must have a defined scope. A cart revision orders that cart; it does not order every event for every user. Gap detection is meaningful only for a stream whose sequence contract promises continuity for that subscriber.

Back to Top


15. Security and Privacy

Same origin is not the same as trusted

An XSS vulnerability in any same-origin tab can publish and receive the same cross-tab messages. Browser-local messaging does not protect against compromised code running under the origin.

Security rules

  • Validate every message type and payload at runtime.
  • Never put access tokens, refresh tokens, passwords, or sensitive personal data in cross-tab messages.
  • Treat channel names as identifiers, not secrets.
  • Validate event.origin and event.source for postMessage.
  • Use exact targetOrigin, not '*', for sensitive communication.
  • Authorize every server-side subscription and event.
  • Apply CSP, Trusted Types, and XSS defenses.
  • Minimize payloads and broadcast invalidations where possible.

Logout

Cross-tab logout is a UX optimization:

server revokes session/token
  -> initiating tab clears local state
  -> initiating tab broadcasts logout
  -> other tabs clear UI state
Enter fullscreen mode Exit fullscreen mode

Other tabs must not remain authorized merely because a message was missed. The server remains authoritative.

Back to Top


16. Decision Guide

Which contexts must communicate?
  |
  +-- Same page/components
  |     -> in-memory state store/event bus
  |
  +-- Parent ↔ iframe or opener ↔ popup
  |     -> postMessage; MessageChannel for a dedicated port
  |
  +-- Arbitrary same-origin tabs
  |     |
  |     +-- Simple events
  |     |     -> BroadcastChannel
  |     |
  |     +-- Compatibility fallback
  |     |     -> storage event
  |     |
  |     +-- One shared socket/state owner
  |     |     -> SharedWorker
  |     |
  |     +-- Network/offline/push coordination
  |     |     -> Service Worker + IndexedDB
  |     |
  |     +-- Exactly one tab performs a task
  |           -> Web Locks + BroadcastChannel
  |
  +-- Different browser instances/devices
        |
        +-- Bidirectional real time
        |     -> WebSocket + backend broker
        |
        +-- Server-to-client only
        |     -> SSE
        |
        +-- Real time not required
              -> polling or normal API refresh
Enter fullscreen mode Exit fullscreen mode

Comparison summary

Requirement Recommended starting point
Logout/theme/cart sync across tabs BroadcastChannel
Old-browser fallback for small events storage event
Popup or iframe integration postMessage
One WebSocket for all tabs SharedWorker
Push/offline/cache coordination Service Worker
Durable local shared state IndexedDB
One tab performs polling Web Locks
Chrome-to-Firefox communication Backend WebSocket/SSE
Cross-device sync Backend durable state + real-time delivery

Back to Top


17. Testing and Observability

Test matrix

Test at least:

  • Two same-origin tabs
  • A tab opened after previous events were sent
  • Sender tab closing during publish
  • Receiver sleeping/backgrounded
  • Browser refresh and reconnect
  • A separate browser instance
  • Chrome and Firefox
  • Offline then reconnect
  • Duplicate and out-of-order server events

Playwright multi-page example

test('logout propagates to another tab', async ({ context }) => {
  const firstTab = await context.newPage();
  const secondTab = await context.newPage();

  await firstTab.goto('/dashboard');
  await secondTab.goto('/dashboard');

  await firstTab.getByRole('button', { name: 'Log out' }).click();

  await expect(secondTab).toHaveURL(/\/login$/);
});
Enter fullscreen mode Exit fullscreen mode

Useful metrics

  • Messages published/received/dropped by transport
  • Schema-validation failures
  • Active tabs/ports per SharedWorker
  • Leader changes and lease duration
  • WebSocket reconnect count
  • Sequence gaps and replay requests
  • Time from server event to tab application
  • Fallback usage by browser/version

Logging

Include messageId, sourceId, event type, schema version, and entity revision. Do not log sensitive payloads.

Back to Top


18. Interview Questions and Answers

How do two tabs communicate by default?

For arbitrary same-origin tabs, start with BroadcastChannel. Use the storage event as a simple compatibility fallback. Related windows or iframes can use postMessage.

Can BroadcastChannel communicate between Chrome and Firefox?

No. They are separate browser instances. Both clients must connect to shared backend infrastructure such as WebSocket, SSE, or polling.

Can Tab 1 and Tab 2 in the same Chrome instance use BroadcastChannel?

Yes, when both tabs use the same origin and eligible storage partition/profile. Create a BroadcastChannel with the same channel name in each tab. A normal tab and a private-browsing tab are not one shared channel environment.

How do you share one WebSocket across tabs?

Use a SharedWorker as the connection owner and connect each tab through a MessagePort. If SharedWorker is not available, elect one tab as leader or allow one connection per tab and manage the extra server load.

Is the storage event a message queue?

No. It is a notification that storage changed. It has no acknowledgement, replay, or durable queue semantics, and the writing tab does not receive its own event.

How do you prevent duplicate work across tabs?

Use Web Locks for same-origin tab coordination, a SharedWorker as a central owner, or a server-issued lease with fencing when correctness must span browsers or devices.

What happens if a tab misses an event?

Transient events should point to an authoritative source of truth. The tab re-reads IndexedDB or server state, or reconnects with a cursor and requests replay.

How do you secure cross-tab communication?

Validate payloads, avoid secrets, close channels on teardown, and remember that any same-origin XSS can access the channel. For postMessage, validate both origin and source. For backend events, authenticate and authorize every subscription.

What is the difference between coordination and communication?

Communication transfers information. Coordination decides who may perform an action. BroadcastChannel communicates; Web Locks coordinates. Production designs often use both.


Summary

Explain the design in plain English

Each tab has its own memory. I use a local message channel to tell other eligible tabs that data changed, not as the only copy of the data. They read the latest state from IndexedDB or the backend. If only one tab should do shared work, I use a coordinator such as Web Locks or a SharedWorker. Other browsers and devices synchronize through the backend. Every client can recover after missing a notification.

Use the smallest mechanism that matches the real boundary:

  1. postMessage for related windows and iframes.
  2. BroadcastChannel for simple same-origin cross-tab events.
  3. storage events as a low-frequency fallback.
  4. SharedWorker for shared in-memory state or one connection.
  5. Service Worker for network, push, and offline coordination.
  6. IndexedDB for durable local state.
  7. Web Locks for one-owner work.
  8. Backend WebSocket, SSE, or polling across browser instances and devices.

Browser-local messages are transient. Keep important state in an authoritative store, validate every message, and design for sleeping tabs, missed events, reconnection, deduplication, and versioning.

Back to Top


Further Reading

Back to Top

Top comments (2)

Collapse
 
idanshalem profile image
Idan Shalem •

One addition to section 14 for the case where no server or IndexedDB holds the state and the open tabs themselves are the source of truth:

A joining tab can bootstrap over the same channel by broadcasting a sync request. The open tabs reply with their current value and version. Multiple tabs may answer, and a reply can already be stale when it arrives because live updates or a local edit happened in the meantime.

The joiner should listen for live updates from the start and accept a bootstrap reply only if its version is newer than the state it currently holds. That keeps a delayed snapshot from undoing a newer change. Version ordering matters here, not reply arrival order.

Collapse
 
zeeshanali0704 profile image
ZeeshanAli-0704 •

Thank you @idanshalem I have also updated article a bit with more context