Skip to content

Tab Sync Design

Low-level design for @stateloom/tab-sync — the cross-tab state synchronization middleware. Covers the BroadcastChannel architecture, sender ID loop prevention, message format, conflict resolution strategies, and graceful degradation.

Overview

The tab-sync middleware synchronizes store state across browser tabs using the BroadcastChannel API. Local state changes are broadcast to other tabs on the same channel, and incoming updates from remote tabs are applied into the local store. The middleware handles loop prevention, conflict resolution, and SSR safety transparently.

Architecture

The broadcast() factory returns a Middleware<T> with three hooks: init, onSet, and onDestroy. All state is held in closures:

Message Format

Every broadcast message follows the BroadcastMessage<T> structure:

typescript
interface BroadcastMessage<T> {
  readonly senderId: string; // Unique per tab instance
  readonly timestamp: number; // Date.now() at send time
  readonly state: Partial<T>; // Partial state payload
}

The senderId enables loop prevention. The timestamp supports timestamp-based conflict resolution. The state field carries the partial state to merge.

Sender ID Generation

Each middleware instance generates a unique sender ID at construction time:

typescript
function generateSenderId(): string {
  if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
    return crypto.randomUUID();
  }
  return `fallback-${Math.random().toString(36).slice(2)}`;
}

The primary path uses crypto.randomUUID() for cryptographic uniqueness. The fallback uses Math.random() for environments without the Crypto API. The sender ID is generated once per broadcast() call, not per message.

Implementation Details

init Hook — Channel Setup and Message Listener

The init hook creates the BroadcastChannel and registers the incoming message handler:

The full message handler:

typescript
channel.onmessage = (event: MessageEvent) => {
  if (!isBroadcastMessage<T>(event.data)) return;
  const msg = event.data;

  // Loop prevention: ignore our own messages
  if (msg.senderId === senderId) return;

  // Apply conflict resolution
  let stateToApply: Partial<T>;
  if (conflictResolution === 'timestamp') {
    if (msg.timestamp <= lastAppliedTimestamp) return;
    lastAppliedTimestamp = msg.timestamp;
    stateToApply = msg.state;
  } else if (typeof conflictResolution === 'function') {
    stateToApply = conflictResolution(msg.state, api.getState());
  } else {
    // last-write-wins (default)
    stateToApply = msg.state;
  }

  isApplyingRemote = true;
  try {
    api.setState(stateToApply);
  } finally {
    isApplyingRemote = false;
  }
};

Loop Prevention

Two mechanisms prevent infinite broadcast loops:

  1. Sender ID check: Each tab ignores messages with its own senderId. This prevents a tab from processing its own outgoing broadcasts, which would happen because BroadcastChannel delivers messages to all listeners on the channel, including the sender's own onmessage handler in some implementations.

  2. isApplyingRemote flag: When applying a remote state update, the isApplyingRemote flag is set to true. The onSet hook checks this flag and skips broadcasting when it's true, preventing re-broadcasting of state received from other tabs.

onSet Hook — Outgoing Broadcast

After calling next(partial), the onSet hook broadcasts the update to other tabs:

typescript
onSet(api, next, partial) {
  next(partial);

  if (isApplyingRemote || channel === undefined) return;

  const payload: Partial<T> = filter !== undefined
    ? filter(api.getState())
    : partial;

  const message: BroadcastMessage<T> = {
    senderId,
    timestamp: Date.now(),
    state: payload,
  };

  channel.postMessage(message);
}

Key behaviors:

  • next(partial) is called first — the local state update always completes before broadcasting
  • The filter transforms the full post-update state into a subset for broadcasting. Without a filter, the raw partial is sent
  • The filter controls what is sent, not what is received. Incoming state is always applied as-is (after conflict resolution)

Conflict Resolution Strategies

Three strategies are supported via the conflictResolution option:

StrategyBehaviorUse Case
'last-write-wins' (default)Always apply incoming stateSimple state like theme, locale
'timestamp'Drop messages older than last appliedPrevent out-of-order application
ConflictResolver<T>Custom merge function (incoming, current) => resolvedDomain-specific merge (e.g., max counter)

The ConflictResolver function receives the incoming partial state and the current local state, returning the partial to actually apply. This allows domain-specific logic like keeping the higher counter value or merging arrays.

Message Validation

The isBroadcastMessage type guard validates incoming MessageEvent.data before processing:

typescript
function isBroadcastMessage<T>(data: unknown): data is BroadcastMessage<T> {
  if (typeof data !== 'object' || data === null) return false;
  const msg = data as Record<string, unknown>;
  return (
    typeof msg['senderId'] === 'string' &&
    typeof msg['timestamp'] === 'number' &&
    typeof msg['state'] === 'object' &&
    msg['state'] !== null
  );
}

This protects against malformed messages from other sources on the same channel. The check uses bracket notation and typeof guards rather than in checks for robustness.

Graceful Degradation (SSR Safety)

The init hook checks for BroadcastChannel availability before creating an instance:

typescript
init(api) {
  if (typeof BroadcastChannel === 'undefined') return;
  channel = new BroadcastChannel(channelName);
  // ...
}

When BroadcastChannel is unavailable (SSR, older browsers, or non-browser environments), the middleware degrades to a no-op:

  • init returns without creating a channel
  • onSet exits immediately when channel === undefined
  • onDestroy skips the channel.close() call

This means the middleware can be safely included in universal/SSR builds without conditional imports or environment checks at the application level.

onDestroy Hook — Channel Cleanup

typescript
onDestroy() {
  if (channel !== undefined) {
    channel.close();
    channel = undefined;
  }
}

Closing the channel stops receiving messages and releases the underlying OS resource. Setting channel to undefined prevents onSet from attempting to post messages after destruction.

Design Decisions

Why BroadcastChannel Instead of localStorage Events

BroadcastChannel provides structured message passing with proper object serialization, while localStorage storage events are string-only and fire on all windows (not just same-origin tabs). BroadcastChannel also does not trigger disk I/O, making it significantly faster for high-frequency state updates.

Why Sender ID Instead of Origin Tab ID

Browser tabs do not have stable, accessible identifiers. Using a random sender ID generated per middleware instance is simpler and more reliable than attempting to derive a tab identity from window properties. The sender ID also naturally handles the case of multiple stores in the same tab with different channels.

Why Filter Operates on Post-Update State

The filter receives the full state after the update (via api.getState()) rather than the raw partial. This allows selecting specific fields to broadcast regardless of which fields were in the partial. If the filter operated on the partial, it could not access fields that were unchanged by the update but still needed for synchronization.

Why isApplyingRemote Uses try/finally

The isApplyingRemote flag is wrapped in try/finally to ensure it is always reset, even if api.setState throws (e.g., due to another middleware in the chain throwing). Without try/finally, a thrown error would leave the flag stuck at true, permanently disabling outgoing broadcasts.

Why last-write-wins Is the Default

Most cross-tab synchronization use cases involve user preferences (theme, locale, sidebar state) where the latest write is the correct value. Timestamp-based resolution adds complexity for cases where message ordering matters, and custom resolvers are available for domain-specific needs. The simplest correct behavior is the best default.

Performance Considerations

ConcernStrategyCost
SerializationBroadcastChannel uses structured clone (not JSON)O(k) where k = state keys in payload
Loop preventionString comparison on senderId + boolean flag checkO(1) per message
Filter executionApplied only on outgoing messages, never on incomingOne function call per local setState
Channel creationSingle BroadcastChannel per middleware instanceOne OS resource per store
Message validationtypeof checks — no parsing or allocationO(1) per incoming message
Timestamp conflictSingle number comparisonO(1) per incoming message

Cross-References