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:
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:
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:
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:
Sender ID check: Each tab ignores messages with its own
senderId. This prevents a tab from processing its own outgoing broadcasts, which would happen becauseBroadcastChanneldelivers messages to all listeners on the channel, including the sender's ownonmessagehandler in some implementations.isApplyingRemoteflag: When applying a remote state update, theisApplyingRemoteflag is set totrue. TheonSethook 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:
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
filtertransforms the full post-update state into a subset for broadcasting. Without a filter, the rawpartialis 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:
| Strategy | Behavior | Use Case |
|---|---|---|
'last-write-wins' (default) | Always apply incoming state | Simple state like theme, locale |
'timestamp' | Drop messages older than last applied | Prevent out-of-order application |
ConflictResolver<T> | Custom merge function (incoming, current) => resolved | Domain-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:
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:
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:
initreturns without creating a channelonSetexits immediately whenchannel === undefinedonDestroyskips thechannel.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
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
| Concern | Strategy | Cost |
|---|---|---|
| Serialization | BroadcastChannel uses structured clone (not JSON) | O(k) where k = state keys in payload |
| Loop prevention | String comparison on senderId + boolean flag check | O(1) per message |
| Filter execution | Applied only on outgoing messages, never on incoming | One function call per local setState |
| Channel creation | Single BroadcastChannel per middleware instance | One OS resource per store |
| Message validation | typeof checks — no parsing or allocation | O(1) per incoming message |
| Timestamp conflict | Single number comparison | O(1) per incoming message |
Cross-References
- Middleware Overview — where tab-sync fits in the middleware taxonomy
- Architecture Overview — layer structure and dependency graph
- Core Design — scope and signal internals
- Store Design — middleware pipeline that tab-sync hooks into
- API Reference:
@stateloom/tab-sync— consumer-facing documentation