Front-end Engineering Lab

Sync State Across Tabs (BroadcastChannel)

Keep application state synchronized across browser tabs

BroadcastChannel enables communication between tabs—perfect for syncing auth, theme, and real-time updates across all open instances of your app.

BroadcastChannel API

// utils/broadcast.ts
class TabSync {
  private channel: BroadcastChannel;

  constructor(channelName: string) {
    this.channel = new BroadcastChannel(channelName);
  }

  send(message: any): void {
    this.channel.postMessage(message);
  }

  listen(callback: (message: any) => void): () => void {
    const handler = (event: MessageEvent) => {
      callback(event.data);
    };

    this.channel.addEventListener('message', handler);

    // Return cleanup function
    return () => {
      this.channel.removeEventListener('message', handler);
    };
  }

  close(): void {
    this.channel.close();
  }
}

export const tabSync = new TabSync('app-sync');

React Hook

// hooks/useSyncedState.ts
export function useSyncedState<T>(
  key: string,
  initialValue: T
): [T, (value: T) => void] {
  const [state, setState] = useState<T>(() => {
    // Load from localStorage
    const saved = loadFromLocalStorage<T>(key);
    return saved !== null ? saved : initialValue;
  });

  const channelRef = useRef<BroadcastChannel | null>(null);

  useEffect(() => {
    // Create channel
    channelRef.current = new BroadcastChannel(key);

    // Listen for updates from other tabs
    const handleMessage = (event: MessageEvent) => {
      setState(event.data);
    };

    channelRef.current.addEventListener('message', handleMessage);

    return () => {
      channelRef.current?.removeEventListener('message', handleMessage);
      channelRef.current?.close();
    };
  }, [key]);

  const setValue = (value: T) => {
    // Update local state
    setState(value);

    // Save to localStorage
    saveToLocalStorage(key, value);

    // Broadcast to other tabs
    channelRef.current?.postMessage(value);
  };

  return [state, setValue];
}

// Usage
export function ThemeToggle() {
  const [theme, setTheme] = useSyncedState('theme', 'light');

  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Theme: {theme}
    </button>
  );
}

Auth Sync

// hooks/useSyncedAuth.ts
interface AuthState {
  user: User | null;
  token: string | null;
}

export function useSyncedAuth() {
  const [auth, setAuth] = useSyncedState<AuthState>('auth', {
    user: null,
    token: null,
  });

  const login = (user: User, token: string) => {
    setAuth({ user, token });
  };

  const logout = () => {
    setAuth({ user: null, token: null });
  };

  return { auth, login, logout };
}

// Usage
export function App() {
  const { auth, logout } = useSyncedAuth();

  // When user logs out in one tab,
  // all tabs log out automatically

  return <div>{auth.user?.name}</div>;
}

Real-Time Notifications Sync

// hooks/useNotificationSync.ts
interface Notification {
  id: string;
  message: string;
  read: boolean;
}

export function useNotificationSync() {
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const channelRef = useRef<BroadcastChannel>();

  useEffect(() => {
    channelRef.current = new BroadcastChannel('notifications');

    channelRef.current.addEventListener('message', (event) => {
      const { type, data } = event.data;

      switch (type) {
        case 'NEW_NOTIFICATION':
          setNotifications(prev => [...prev, data]);
          break;
        case 'MARK_READ':
          setNotifications(prev =>
            prev.map(n =>
              n.id === data.id ? { ...n, read: true } : n
            )
          );
          break;
        case 'CLEAR_ALL':
          setNotifications([]);
          break;
      }
    });

    return () => channelRef.current?.close();
  }, []);

  const addNotification = (notification: Notification) => {
    setNotifications(prev => [...prev, notification]);
    channelRef.current?.postMessage({
      type: 'NEW_NOTIFICATION',
      data: notification,
    });
  };

  const markAsRead = (id: string) => {
    setNotifications(prev =>
      prev.map(n => (n.id === id ? { ...n, read: true } : n))
    );
    channelRef.current?.postMessage({
      type: 'MARK_READ',
      data: { id },
    });
  };

  const clearAll = () => {
    setNotifications([]);
    channelRef.current?.postMessage({ type: 'CLEAR_ALL' });
  };

  return {
    notifications,
    addNotification,
    markAsRead,
    clearAll,
  };
}

Cart Sync (E-commerce)

// hooks/useCartSync.ts
interface CartItem {
  id: string;
  name: string;
  quantity: number;
  price: number;
}

export function useCartSync() {
  const [cart, setCart] = useSyncedState<CartItem[]>('cart', []);

  const addToCart = (item: CartItem) => {
    setCart(prev => {
      const existing = prev.find(i => i.id === item.id);
      
      if (existing) {
        return prev.map(i =>
          i.id === item.id
            ? { ...i, quantity: i.quantity + item.quantity }
            : i
        );
      }
      
      return [...prev, item];
    });
  };

  const removeFromCart = (id: string) => {
    setCart(prev => prev.filter(item => item.id !== id));
  };

  const updateQuantity = (id: string, quantity: number) => {
    setCart(prev =>
      prev.map(item =>
        item.id === id ? { ...item, quantity } : item
      )
    );
  };

  const clearCart = () => {
    setCart([]);
  };

  const total = cart.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  );

  return {
    cart,
    total,
    addToCart,
    removeFromCart,
    updateQuantity,
    clearCart,
  };
}

Leader Election (One Tab Manages Task)

// utils/leader-election.ts
export function useLeaderElection() {
  const [isLeader, setIsLeader] = useState(false);
  const channelRef = useRef<BroadcastChannel>();
  const leaderCheckRef = useRef<NodeJS.Timeout>();

  useEffect(() => {
    channelRef.current = new BroadcastChannel('leader-election');

    // Check if already a leader
    let hasLeader = false;

    channelRef.current.addEventListener('message', (event) => {
      if (event.data.type === 'LEADER_ALIVE') {
        hasLeader = true;
      } else if (event.data.type === 'LEADER_RESIGN') {
        hasLeader = false;
        tryBecomeLeader();
      }
    });

    const tryBecomeLeader = () => {
      // Ask if there's a leader
      channelRef.current?.postMessage({ type: 'LEADER_CHECK' });

      setTimeout(() => {
        if (!hasLeader) {
          setIsLeader(true);
          
          // Announce leadership
          channelRef.current?.postMessage({ type: 'LEADER_ALIVE' });
          
          // Keep announcing
          leaderCheckRef.current = setInterval(() => {
            channelRef.current?.postMessage({ type: 'LEADER_ALIVE' });
          }, 5000);
        }
      }, 100);
    };

    // Try to become leader
    tryBecomeLeader();

    // Announce resignation before unload
    const handleBeforeUnload = () => {
      if (isLeader) {
        channelRef.current?.postMessage({ type: 'LEADER_RESIGN' });
      }
    };

    window.addEventListener('beforeunload', handleBeforeUnload);

    return () => {
      clearInterval(leaderCheckRef.current);
      window.removeEventListener('beforeunload', handleBeforeUnload);
      if (isLeader) {
        channelRef.current?.postMessage({ type: 'LEADER_RESIGN' });
      }
      channelRef.current?.close();
    };
  }, [isLeader]);

  return isLeader;
}

// Usage: Only leader tab polls for updates
export function AutoRefresh() {
  const isLeader = useLeaderElection();

  useEffect(() => {
    if (!isLeader) return;

    // Only leader polls
    const interval = setInterval(() => {
      fetchUpdates();
    }, 10000);

    return () => clearInterval(interval);
  }, [isLeader]);

  return isLeader ? <div>🎯 Leader Tab</div> : null;
}

Fallback for Unsupported Browsers

// utils/cross-tab-sync.ts
export function createCrossTabSync(key: string) {
  // Check BroadcastChannel support
  if (typeof BroadcastChannel !== 'undefined') {
    return new BroadcastChannel(key);
  }

  // Fallback: Use localStorage events
  return {
    postMessage: (data: any) => {
      localStorage.setItem(`${key}-sync`, JSON.stringify({
        data,
        timestamp: Date.now(),
      }));
    },
    addEventListener: (_type: string, callback: (e: any) => void) => {
      const handler = (e: StorageEvent) => {
        if (e.key === `${key}-sync` && e.newValue) {
          const { data } = JSON.parse(e.newValue);
          callback({ data });
        }
      };
      window.addEventListener('storage', handler);
      return () => window.removeEventListener('storage', handler);
    },
    close: () => {},
  };
}

Best Practices

  1. Namespace channels: Use unique names per feature
  2. Version messages: Handle schema changes
  3. Handle duplicates: Same tab receives message
  4. Clean up: Close channels on unmount
  5. Sync on visibility: Update when tab becomes visible
  6. Leader election: For shared tasks
  7. Test across tabs: Open multiple tabs
  8. Fallback support: localStorage events
  9. Limit message size: Keep under 1MB
  10. Document protocol: Message types and structure

Cross-tab sync creates a unified experience—changes in one tab instantly reflect in all others!

On this page