Have 30-200 Employees? Make $50k-$500k selling your data for AI Training.

Learn More
SitePoint Premium
Stay Relevant and Grow Your Career in Tech
  • Premium Results
  • Publish articles on SitePoint
  • Daily curated jobs
  • Learning Paths
  • Discounts to dev tools
Start Free Trial

7 Day Free Trial. Cancel Anytime.

How to Synchronize State Across Browser Tabs

  1. Define a typed message envelope with sender ID, timestamp, and version fields for every cross-tab payload.
  2. Detect BroadcastChannel support at runtime using a typeof guard before constructing a channel.
  3. Create a dual-transport abstraction that wraps BroadcastChannel and falls back to localStorage storage events automatically.
  4. Filter incoming messages by sender ID and a seen-message ring buffer to prevent self-echoes and duplicates.
  5. Resolve conflicts with a last-write-wins timestamp comparison against locally held state.
  6. Broadcast state changes (auth, theme, cart) through the abstraction layer using a single send() call.
  7. Clean up channels and event listeners on pagehide to avoid memory leaks and bfcache conflicts.
  8. Verify cross-tab delivery with Playwright multi-page tests that open real browser contexts.

Users expect consistent state across every open browser tab. When that expectation breaks, shopping carts diverge between tabs, authentication tokens expire silently in a background tab while the user keeps working in another, and theme toggles apply in one context but leave every other tab visually stale. The Broadcast Channel API offers the simplest browser-native primitive for cross-tab synchronization (four methods, no manual serialization), but Safari's late adoption, fallback wiring, and deduplication logic mean a production deployment requires more than the API basics.

This article walks through a TypeScript-first implementation that wraps the Broadcast Channel API with an automatic localStorage storage event fallback, adds conflict resolution and deduplication, and verifies the entire system with a Playwright cross-tab test harness.

Table of Contents

How the Broadcast Channel API Works

Core Concepts and Browser Support

The Broadcast Channel API allows simple communication between browsing contexts—tabs, windows, iframes, and workers—that share the same origin. You create a channel by passing a name string to the BroadcastChannel constructor. Any context that creates a channel with the same name on the same origin joins the group. When all references to a channel are garbage collected or explicitly closed, the browser releases the port.

Browser support is broad but not universal enough to skip fallbacks. Chrome has supported the API since version 54 (2016), Firefox since 38 (2015), and Edge since version 79 (Chromium-based). Safari did not add support until version 15.4 (March 2022) on macOS; on iOS, BroadcastChannel requires iOS 15.4 or later, as the WebKit version is tied to the OS. This late Safari addition is the primary motivation for building an automatic fallback path into any production implementation.

Lifecycle of a Broadcast Message

When a tab calls postMessage() on a BroadcastChannel instance, every other same-origin context that holds an open channel with the same name receives the message via its onmessage handler. The API never echoes the message back to the sender. It uses the browser's structured clone algorithm for serialization, meaning it can clone objects, arrays, ArrayBuffer, Map, Set, and other structured types without manual JSON conversion.

// Code Example 1 — Minimal BroadcastChannel round-trip

interface ThemeMessage {
  type: "THEME_CHANGE";
  theme: "light" | "dark";
}

// In any tab: create a named channel
const channel = new BroadcastChannel("app-sync");

// Listen for messages from other tabs
channel.onmessage = (event: MessageEvent<ThemeMessage>) => {
  console.log("Received in this tab:", event.data.theme);
};

// Send a message — only OTHER tabs/contexts will receive it
const msg: ThemeMessage = { type: "THEME_CHANGE", theme: "dark" };
channel.postMessage(msg);

// Clean up when done
channel.close();

This establishes the baseline API surface: construct, listen, post, close. Everything that follows builds abstraction on top of these four operations.

Designing TypeScript Interfaces for Multi-Tab Messages

Payload Envelope Structure

A multi-tab messaging system that handles conflicts and deduplication needs more than raw payloads. Wrapping every message in a typed envelope provides the metadata you need to resolve conflicts, deduplicate, and prevent echoes. The SyncMessage generic envelope carries a type discriminator, the payload itself, a senderId (a UUID generated once per tab), a timestamp (millisecond-precision Date.now()), and a version number for schema evolution.

The senderId field is especially important on the localStorage fallback path, where the storage event fires in every other tab but no built-in mechanism prevents a tab from reacting to its own writes without explicit filtering.

Discriminated Union for Action Types

Defining action types as a discriminated union enables exhaustive switch/case narrowing. TypeScript will flag any unhandled action at compile time, catching integration bugs before they reach production.

Defining action types as a discriminated union enables exhaustive switch/case narrowing. TypeScript will flag any unhandled action at compile time, catching integration bugs before they reach production.

// Code Example 2 — TypeScript interfaces and discriminated union
// Note: crypto.randomUUID() requires a secure context (HTTPS or localhost).
// The generateTabId() helper below provides a fallback for non-secure contexts.

type ActionType = "STATE_UPDATE" | "AUTH_CHANGE" | "PING" | "PONG";

interface SyncMessage<T = unknown> {
  type: ActionType;
  payload: T;
  senderId: string;
  timestamp: number;
  version: number;
  messageId: string;
}

interface AuthPayload {
  authenticated: boolean;
  userId?: string;
}

interface StatePayload {
  key: string;
  value: unknown;
}

// Safe TAB_ID: handles non-secure contexts (HTTP) gracefully
function generateTabId(): string {
  if (
    typeof crypto !== "undefined" &&
    typeof crypto.randomUUID === "function"
  ) {
    try {
      return crypto.randomUUID();
    } catch {
      // Non-secure context or restricted environment
    }
  }
  // Fallback: sufficient entropy for tab-scoped deduplication
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
}

export const TAB_ID = generateTabId();

function generateMessageId(): string {
  if (
    typeof crypto !== "undefined" &&
    typeof crypto.randomUUID === "function"
  ) {
    try {
      return crypto.randomUUID();
    } catch {
      // Non-secure context or restricted environment
    }
  }
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
}

// Factory function
function createMessage<T>(
  type: ActionType,
  payload: T
): SyncMessage<T> {
  return {
    type,
    payload,
    senderId: TAB_ID,
    timestamp: Date.now(),
    version: 1,
    messageId: generateMessageId(),
  };
}

// Type narrowing in a handler
function handleAction(msg: SyncMessage) {
  switch (msg.type) {
    case "AUTH_CHANGE": {
      const auth = msg.payload as AuthPayload;
      console.log("Auth changed:", auth.authenticated);
      break;
    }
    case "STATE_UPDATE": {
      const state = msg.payload as StatePayload;
      console.log("State update:", state.key, state.value);
      break;
    }
    case "PING": {
      // respond with PONG
      break;
    }
    case "PONG": {
      // record alive tab
      break;
    }
  }
}

Building a Dual-Transport Abstraction Layer

Prerequisites

The code examples in this article assume the following TypeScript configuration:

// tsconfig.json (minimum required settings)
{
  "compilerOptions": {
    "lib": ["ES2022", "DOM"],
    "target": "ES2020",
    "strict": true
  }
}

The TabSyncChannel Class

The abstraction layer wraps both transport mechanisms behind a single interface. The constructor checks whether BroadcastChannel is available via a typeof guard; if the API is not present, it falls back to localStorage writes paired with the storage event listener. A private transport flag records which path is active, useful for diagnostics and logging.

// Code Example 3 — TabSyncChannel class with feature detection and fallback
// Requires types and helpers from Code Example 2 (SyncMessage, MessageCallback, etc.)

type TransportType = "broadcast" | "storage";
type MessageCallback<T> = (msg: SyncMessage<T>) => void;

const STORAGE_KEY_PREFIX = "__tab_sync__";

class TabSyncChannel<T = unknown> {
  private bc: BroadcastChannel | null = null;
  private transport: TransportType;
  private channelName: string;
  private listeners: MessageCallback<T>[] = [];
  private storageHandler: ((e: StorageEvent) => void) | null = null;
  private closed = false;

  constructor(name: string) {
    this.channelName = name;

    if (typeof BroadcastChannel !== "undefined") {
      this.bc = new BroadcastChannel(name);
      this.transport = "broadcast";

      this.bc.onmessage = (event: MessageEvent<SyncMessage<T>>) => {
        this.listeners.forEach((cb) => cb(event.data));
      };

      // Handle structured-clone failures and other channel errors
      this.bc.onmessageerror = (event: MessageEvent) => {
        console.error(
          "[TabSyncChannel] messageerror on channel",
          this.channelName,
          event
        );
      };
    } else {
      this.transport = "storage";
      if (typeof window !== "undefined") {
        this.initStorageFallback();
      }
    }
  }

  private initStorageFallback(): void {
    const keyPrefix = `${STORAGE_KEY_PREFIX}${this.channelName}__`;

    this.storageHandler = (e: StorageEvent) => {
      // Match all per-message keys for this channel
      if (!e.key || !e.key.startsWith(keyPrefix) || e.newValue === null) return;

      try {
        const msg: SyncMessage<T> = JSON.parse(e.newValue);
        this.listeners.forEach((cb) => cb(msg));
      } catch {
        // Malformed JSON — discard silently.
        // In production, consider logging this to your error-tracking service.
      }
    };

    window.addEventListener("storage", this.storageHandler);
  }

  send(msg: SyncMessage<T>): void {
    if (this.transport === "broadcast" && this.bc) {
      this.bc.postMessage(msg);
    } else {
      const key = `${STORAGE_KEY_PREFIX}${this.channelName}`;
      // Use a unique key per message to avoid removal-before-read races.
      // Receiving tabs match on the key prefix in initStorageFallback().
      const msgKey = `${key}__${msg.messageId}`;
      localStorage.setItem(msgKey, JSON.stringify(msg));
      // Defer removal: give receiving tabs time to read the storage event.
      setTimeout(() => {
        localStorage.removeItem(msgKey);
      }, 200);
    }
  }

  onReceive(callback: MessageCallback<T>): void {
    if (this.closed) {
      console.warn("[TabSyncChannel] onReceive called after close(); ignoring.");
      return;
    }
    this.listeners.push(callback);
  }

  close(): void {
    this.closed = true;
    if (this.bc) {
      this.bc.close();
      this.bc = null;
    }
    if (this.storageHandler && typeof window !== "undefined") {
      window.removeEventListener("storage", this.storageHandler);
      this.storageHandler = null;
    }
    this.listeners = [];
  }

  getTransport(): TransportType {
    return this.transport;
  }
}

Two details in the localStorage path deserve attention. First, the class writes each message to a unique key (incorporating the messageId) and removes it after a 200 ms delay, which prevents both quota accumulation and the race condition where an immediate removeItem could cause the receiving tab to read a null newValue from the storage event. Second, the storage event never fires in the tab that performed the write, mirroring the no-self-echo behavior of BroadcastChannel. (This behavior is specified in the WHATWG HTML standard, though some very old browsers exhibited bugs here.)

BroadcastChannel vs localStorage Storage Event: Trade-offs

The Broadcast Channel API uses the structured clone algorithm, meaning it can pass Map, Set, Date, ArrayBuffer, and other complex types without serialization. The localStorage path requires JSON serialization and deserialization, which loses type fidelity for anything beyond plain objects, arrays, strings, numbers, booleans, and null.

Storage quota matters too. The localStorage fallback writes to disk-backed storage with a per-origin limit that varies by browser (Chrome caps it at 10 MB; Safari and Firefox default to 5 MB). The Broadcast Channel API operates entirely in memory with no persistent storage cost. Cross-origin restrictions are identical in both: same-origin only.

Latency and ordering are comparable for low-frequency messages, but neither transport guarantees ordering when multiple tabs send concurrently; BroadcastChannel does preserve order for messages from a single sender. Applications sending high-frequency updates from multiple tabs need their own sequencing mechanism.

Handling Conflicts and Edge Cases

Last-Write-Wins with Timestamps

When multiple tabs emit state updates concurrently, the receiving tab needs a strategy to decide which update to apply. The simplest approach is last-write-wins: compare the timestamp field on the incoming message against the timestamp of the locally held state, and discard any message whose timestamp is older. This works well for independent state slices like theme or auth status. For applications requiring stronger consistency, such as collaborative editing, vector clocks or Lamport timestamps provide causal ordering, but they require each tab to maintain per-tab counters and implement merge logic, which is beyond the scope of this implementation.

Deduplication and Self-Echo Prevention

On the BroadcastChannel path, the browser handles self-echo prevention. On the localStorage path, the storage event similarly does not fire in the originating tab. However, in edge cases involving rapid reconnects or multiple channels, an explicit senderId check provides a safety net. Additionally, a ring buffer of recently seen messageId values catches duplicates that might arise from retries or storage event quirks.

Tab Close and Channel Cleanup

Failing to close a channel or clean up event listeners when a tab unloads can cause memory leaks and phantom message handlers. Binding cleanup to pagehide covers desktop and mobile browsers and is compatible with the back-forward cache (bfcache). Note: adding a beforeunload listener would also work for cleanup but prevents the page from entering bfcache, so pagehide is preferred in most cases. On the localStorage fallback path, ensuring that temporary keys are removed prevents zombie entries from accumulating.

Failing to close a channel or clean up event listeners when a tab unloads can cause memory leaks and phantom message handlers. Binding cleanup to pagehide covers desktop and mobile browsers and is compatible with the back-forward cache (bfcache).

// Code Example 4 — Conflict resolution and deduplication logic
// Requires types from Code Example 2 (SyncMessage)

class MessageHandler<T> {
  private lastTimestamps = new Map<string, number>(); // key → timestamp
  private seenIds = new Set<string>();
  private maxSeenSize = 500;
  private tabId: string;

  constructor(tabId: string) {
    this.tabId = tabId;
  }

  handleIncoming(
    msg: SyncMessage<T>,
    stateKey: string,
    apply: (payload: T) => void
  ): boolean {
    // Self-echo prevention
    if (msg.senderId === this.tabId) return false;

    // Deduplication
    if (this.seenIds.has(msg.messageId)) return false;
    this.seenIds.add(msg.messageId);

    // Evict oldest entries if the set grows too large
    if (this.seenIds.size > this.maxSeenSize) {
      const first = this.seenIds.values().next().value;
      if (first !== undefined) this.seenIds.delete(first);
    }

    // Last-write-wins timestamp comparison
    const lastTs = this.lastTimestamps.get(stateKey) ?? 0;
    if (msg.timestamp <= lastTs) return false;

    this.lastTimestamps.set(stateKey, msg.timestamp);
    apply(msg.payload);
    return true;
  }
}

Practical Integration Example: Syncing Auth State

Scenario Setup

A user logs out in Tab A. Every other open tab must detect the logout and redirect to the login screen. Without cross-tab synchronization, background tabs remain authenticated, displaying sensitive data or making API calls with a revoked token.

Wiring TabSyncChannel into an App

// Code Example 5 — Auth state sync integration
// Requires types and helpers from Code Examples 2–4 (SyncMessage, AuthPayload, createMessage, TabSyncChannel, MessageHandler)
// Important: reuse TAB_ID from Code Example 2 so that MessageHandler's self-echo
// check matches the senderId that TabSyncChannel/createMessage stamps on messages.

import { TAB_ID, TabSyncChannel, MessageHandler, createMessage } from "./tabSync";

const authChannel = new TabSyncChannel<AuthPayload>("auth-sync");
const handler = new MessageHandler<AuthPayload>(TAB_ID);

// Subscribe to auth changes from other tabs
authChannel.onReceive((msg) => {
  if (msg.type !== "AUTH_CHANGE") return;

  handler.handleIncoming(msg, "auth", (payload) => {
    if (!payload.authenticated) {
      // Remove only auth-related keys; clearing all sessionStorage may break unrelated modules.
      sessionStorage.removeItem("authToken");
      sessionStorage.removeItem("userId");

      // Redirect to login, but guard against redirect loops
      if (window.location.pathname !== "/login") {
        window.location.href = "/login";
      }
    }
  });
});

// When the current tab triggers logout
function logout(): void {
  // Perform local logout logic — remove only auth-related keys
  sessionStorage.removeItem("authToken");
  sessionStorage.removeItem("userId");
  document.cookie = "session=; Max-Age=0; path=/";

  // Notify other tabs
  const msg = createMessage<AuthPayload>("AUTH_CHANGE", {
    authenticated: false,
  });
  authChannel.send(msg);

  window.location.href = "/login";
}

// Cleanup on tab close
window.addEventListener("pagehide", () => {
  authChannel.close();
});

Testing Cross-Tab Behavior with Playwright

Why Unit Tests Are Not Enough

The BroadcastChannel API requires separate browsing contexts. JSDOM, the DOM implementation used by Jest and Vitest in their default configurations, does not support BroadcastChannel. Mocking it out defeats the purpose: the test would verify mock behavior, not actual cross-context message delivery. Integration tests that open real browser tabs are the only way to confirm the system works end to end.

Test Prerequisites

Before running the Playwright tests below, ensure the following are in place:

  1. Install Playwright: npm install -D @playwright/test
  2. Install browsers: npx playwright install chromium
  3. Create a minimal playwright.config.ts:
import { defineConfig } from "@playwright/test";

export default defineConfig({
  timeout: 15000,
  expect: {
    timeout: 5000,
  },
  use: {
    baseURL: "http://localhost:3000",
  },
  webServer: {
    command: "npm run dev", // Adjust to your dev server start command
    port: 3000,
    reuseExistingServer: true,
  },
});

4. Expose TabSyncChannel on window in your application entry point so that Playwright evaluate blocks can access it:

// In your app's entry file:
(window as any).TabSyncChannel = TabSyncChannel;

Writing a Multi-Page Playwright Test

Playwright can open multiple pages within the same browser context, which shares origin and session state. This mirrors a user with two tabs open to the same site.

Playwright can open multiple pages within the same browser context, which shares origin and session state. This mirrors a user with two tabs open to the same site.

// Code Example 6 — Playwright cross-tab test

import { test, expect } from "@playwright/test";

test("BroadcastChannel delivers messages between tabs", async ({
  context,
}) => {
  const page1 = await context.newPage();
  const page2 = await context.newPage();

  // Navigate both pages to the same origin
  await page1.goto("http://localhost:3000");
  await page2.goto("http://localhost:3000");

  // Set up a receiver in page2 and wait for the listener to be ready
  const received = page2.evaluate(() => {
    return new Promise<string>((resolve, reject) => {
      const ch = new BroadcastChannel("test-channel");
      const timer = setTimeout(() => {
        ch.close();
        reject(new Error("Timeout: no message received within 5 s"));
      }, 5000);

      ch.onmessage = (e) => {
        clearTimeout(timer);
        ch.close();
        resolve(e.data.type);
      };
      // Signal that the listener is registered by setting a global flag
      (window as any).__listenerReady = true;
    });
  });

  // Ensure page2's listener is registered before sending
  await page2.waitForFunction(() => (window as any).__listenerReady === true);

  // Send from page1
  await page1.evaluate(() => {
    const ch = new BroadcastChannel("test-channel");
    ch.postMessage({ type: "PING" });
    ch.close();
  });

  const msgType = await received;
  expect(msgType).toBe("PING");
});

test("Falls back to localStorage when BroadcastChannel is unavailable", async ({
  context,
}) => {
  const page1 = await context.newPage();
  const page2 = await context.newPage();

  // Disable BroadcastChannel BEFORE page scripts run
  for (const page of [page1, page2]) {
    await page.addInitScript(() => {
      delete (window as any).BroadcastChannel;
    });
  }

  await page1.goto("http://localhost:3000");
  await page2.goto("http://localhost:3000");

  // Set up receiver in page2 using the TabSyncChannel abstraction
  const received = page2.evaluate(() => {
    return new Promise<boolean>((resolve, reject) => {
      const ch = new (window as any).TabSyncChannel("fallback-test");
      // Verify the fallback transport is active
      if (ch.getTransport() !== "storage") {
        reject(new Error("Expected storage transport, got " + ch.getTransport()));
        return;
      }

      const timer = setTimeout(() => {
        ch.close();
        reject(new Error("Timeout: fallback message not received within 5 s"));
      }, 5000);

      ch.onReceive((msg: any) => {
        clearTimeout(timer);
        const result = msg.type === "PING";
        // Defer close() outside of the forEach call stack
        setTimeout(() => ch.close(), 0);
        resolve(result);
      });
      (window as any).__fallbackReady = true;
    });
  });

  // Ensure page2's listener is registered before sending
  await page2.waitForFunction(() => (window as any).__fallbackReady === true);

  // Send from page1
  await page1.evaluate(() => {
    const ch = new (window as any).TabSyncChannel("fallback-test");
    const msg = {
      type: "PING",
      payload: null,
      senderId: "test-tab-1",
      timestamp: Date.now(),
      version: 1,
      messageId: "test-msg-1",
    };
    ch.send(msg);
  });

  const result = await received;
  expect(result).toBe(true);
});

The second test uses page.addInitScript() to remove the BroadcastChannel constructor before any page script runs, ensuring TabSyncChannel falls back to the localStorage transport. Both tests include synchronization guards to prevent race conditions between listener registration and message sending, and timeout rejection paths to prevent indefinite hangs in CI.

Performance and Production Considerations

High-frequency state updates, such as cursor positions or real-time form field changes, should be throttled to 50-200 ms intervals depending on your use case. Note: requestIdleCallback is unsupported in Safari; use setTimeout(fn, 0) as a cross-browser alternative. Keep payloads small; serializing entire application state trees on every change is wasteful on both transports and risks hitting the localStorage quota limit on the fallback path. For complex multi-origin scenarios, upgrading to a SharedWorker or Service Worker relay provides more control over message routing and lifecycle, though at the cost of significant additional complexity.

Key Takeaways

The Broadcast Channel API is the simplest browser-native primitive for same-origin, cross-tab communication: four methods, no serialization overhead. Wrapping it with a localStorage storage event fallback covers the remaining browser gaps, particularly older Safari versions, without changing the public API surface. TypeScript interfaces and discriminated unions enforce message contracts at compile time, catching integration errors before they surface as silent cross-tab failures. And Playwright provides the only realistic test harness for verifying that messages actually traverse separate browsing contexts. All code presented here is a starting point. Review each snippet for your target environment, browser support requirements, and security context before production use.

SitePoint TeamSitePoint Team

Sharing our passion for building incredible internet things.

© 2000 – 2026 SitePoint Pty. Ltd.
This site is protected by reCAPTCHA and the Google Privacy Policy and Terms of Service apply.