Web APIs

JavaScript Broadcast Channel API: Complete Cross-Context Communication Guide

Master the Broadcast Channel API for communication between browser contexts. Learn messaging patterns, synchronization, and real-time updates.

By JavaScriptDoc Team
broadcastmessagingcommunicationtabsjavascript

JavaScript Broadcast Channel API: Complete Cross-Context Communication Guide

The Broadcast Channel API enables simple communication between different browsing contexts (tabs, windows, iframes) of the same origin, allowing you to synchronize state and share messages across your application.

Understanding the Broadcast Channel API

The Broadcast Channel API provides a simple message bus for communication between different contexts of the same origin, without needing a server or complex setup.

// Check Broadcast Channel API support
if ('BroadcastChannel' in window) {
  console.log('Broadcast Channel API is supported');
} else {
  console.log('Broadcast Channel API is not supported');
}

// Create a channel
const channel = new BroadcastChannel('my-channel');

// Send a message
channel.postMessage({ type: 'greeting', data: 'Hello from tab 1!' });

// Receive messages
channel.onmessage = (event) => {
  console.log('Received:', event.data);
};

// Close channel when done
channel.close();

// Message event properties
/*
event = {
  data: any,           // The message data
  origin: string,      // Origin of the sender
  lastEventId: string, // Not used in BroadcastChannel
  source: null,        // Always null for BroadcastChannel
  ports: []           // Always empty for BroadcastChannel
}
*/

Basic Channel Communication

Channel Manager

class ChannelManager {
  constructor(channelName) {
    this.channelName = channelName;
    this.channel = null;
    this.handlers = new Map();
    this.id = this.generateId();
    this.isConnected = false;
    this.reconnectAttempts = 0;
    this.maxReconnectAttempts = 5;

    this.connect();
  }

  // Connect to channel
  connect() {
    try {
      if (!('BroadcastChannel' in window)) {
        throw new Error('BroadcastChannel API is not supported');
      }

      this.channel = new BroadcastChannel(this.channelName);
      this.isConnected = true;
      this.reconnectAttempts = 0;

      // Setup message handling
      this.channel.onmessage = this.handleMessage.bind(this);

      // Setup error handling
      this.channel.onmessageerror = this.handleError.bind(this);

      // Announce presence
      this.announce();

      console.log(`Connected to channel: ${this.channelName}`);
    } catch (error) {
      console.error('Failed to connect to channel:', error);
      this.handleConnectionError(error);
    }
  }

  // Handle incoming messages
  handleMessage(event) {
    const message = event.data;

    // Ignore own messages
    if (message.senderId === this.id) {
      return;
    }

    // Log message
    this.logMessage('received', message);

    // Handle by type
    if (message.type && this.handlers.has(message.type)) {
      const handlers = this.handlers.get(message.type);
      handlers.forEach((handler) => {
        try {
          handler(message.data, message);
        } catch (error) {
          console.error(`Handler error for ${message.type}:`, error);
        }
      });
    }

    // Global message handler
    if (this.handlers.has('*')) {
      const handlers = this.handlers.get('*');
      handlers.forEach((handler) => handler(message));
    }
  }

  // Handle errors
  handleError(event) {
    console.error('Message error:', event);
    this.emit('error', { error: event });
  }

  // Handle connection errors
  handleConnectionError(error) {
    this.isConnected = false;

    if (this.reconnectAttempts < this.maxReconnectAttempts) {
      this.reconnectAttempts++;
      const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);

      console.log(`Reconnecting in ${delay}ms...`);
      setTimeout(() => this.connect(), delay);
    } else {
      console.error('Max reconnection attempts reached');
      this.emit('connection-failed', { error });
    }
  }

  // Send message
  send(type, data = {}, options = {}) {
    if (!this.isConnected) {
      console.warn('Channel not connected');
      return false;
    }

    const message = {
      type,
      data,
      senderId: this.id,
      timestamp: Date.now(),
      ...options,
    };

    try {
      this.channel.postMessage(message);
      this.logMessage('sent', message);
      return true;
    } catch (error) {
      console.error('Failed to send message:', error);
      this.handleError(error);
      return false;
    }
  }

  // Subscribe to message type
  on(type, handler) {
    if (!this.handlers.has(type)) {
      this.handlers.set(type, new Set());
    }

    this.handlers.get(type).add(handler);

    // Return unsubscribe function
    return () => {
      const handlers = this.handlers.get(type);
      if (handlers) {
        handlers.delete(handler);
        if (handlers.size === 0) {
          this.handlers.delete(type);
        }
      }
    };
  }

  // Emit message (alias for send)
  emit(type, data) {
    return this.send(type, data);
  }

  // Request-response pattern
  request(type, data = {}, timeout = 5000) {
    return new Promise((resolve, reject) => {
      const requestId = this.generateId();
      const responseType = `${type}-response`;

      // Setup response handler
      const cleanup = this.on(responseType, (response) => {
        if (response.requestId === requestId) {
          cleanup();
          clearTimeout(timeoutId);
          resolve(response);
        }
      });

      // Setup timeout
      const timeoutId = setTimeout(() => {
        cleanup();
        reject(new Error(`Request timeout: ${type}`));
      }, timeout);

      // Send request
      this.send(type, { ...data, requestId });
    });
  }

  // Respond to request
  respond(originalMessage, responseData) {
    const responseType = `${originalMessage.type}-response`;

    this.send(responseType, {
      ...responseData,
      requestId: originalMessage.data.requestId,
    });
  }

  // Announce presence
  announce() {
    this.send('presence', {
      action: 'join',
      context: this.getContextInfo(),
    });
  }

  // Get context information
  getContextInfo() {
    return {
      id: this.id,
      url: window.location.href,
      title: document.title,
      timestamp: Date.now(),
      userAgent: navigator.userAgent,
    };
  }

  // Close channel
  close() {
    if (this.channel) {
      // Announce departure
      this.send('presence', {
        action: 'leave',
        context: this.getContextInfo(),
      });

      this.channel.close();
      this.channel = null;
      this.isConnected = false;

      console.log(`Disconnected from channel: ${this.channelName}`);
    }
  }

  // Generate unique ID
  generateId() {
    return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
  }

  // Log messages (for debugging)
  logMessage(direction, message) {
    if (this.debug) {
      console.log(`[${this.channelName}] ${direction}:`, message);
    }
  }
}

// Usage
const channel = new ChannelManager('app-sync');

// Listen for messages
channel.on('user-update', (data) => {
  console.log('User updated:', data);
});

// Send message
channel.send('user-update', {
  userId: 123,
  name: 'John Doe',
  action: 'profile-edit',
});

// Request-response
channel
  .request('get-data', { key: 'settings' })
  .then((response) => console.log('Settings:', response))
  .catch((error) => console.error('Request failed:', error));

// Handle requests
channel.on('get-data', (data, message) => {
  const value = localStorage.getItem(data.key);
  channel.respond(message, { value });
});

State Synchronization

Cross-Tab State Manager

class CrossTabStateManager {
  constructor(options = {}) {
    this.options = {
      channelName: 'state-sync',
      debounceDelay: 100,
      conflictResolution: 'last-write-wins',
      persistState: true,
      ...options,
    };

    this.state = {};
    this.localChanges = new Set();
    this.channel = new ChannelManager(this.options.channelName);
    this.subscribers = new Map();
    this.stateVersion = 0;

    this.init();
  }

  // Initialize state manager
  init() {
    // Load persisted state
    if (this.options.persistState) {
      this.loadPersistedState();
    }

    // Setup channel handlers
    this.setupChannelHandlers();

    // Request initial state sync
    this.requestStateSync();

    // Setup unload handler
    window.addEventListener('beforeunload', () => {
      this.channel.close();
    });
  }

  // Setup channel handlers
  setupChannelHandlers() {
    // State update handler
    this.channel.on('state-update', (data) => {
      this.handleRemoteUpdate(data);
    });

    // State sync request handler
    this.channel.on('state-sync-request', (data, message) => {
      this.handleSyncRequest(message);
    });

    // State sync response handler
    this.channel.on('state-sync-response', (data) => {
      this.handleSyncResponse(data);
    });

    // Conflict resolution
    this.channel.on('state-conflict', (data) => {
      this.handleConflict(data);
    });
  }

  // Get state value
  get(path, defaultValue = undefined) {
    const value = this.getValueByPath(this.state, path);
    return value !== undefined ? value : defaultValue;
  }

  // Set state value
  set(path, value, options = {}) {
    const oldValue = this.get(path);

    if (oldValue === value && !options.force) {
      return; // No change
    }

    // Update local state
    this.setValueByPath(this.state, path, value);
    this.stateVersion++;

    // Track local change
    this.localChanges.add(path);

    // Notify local subscribers
    this.notifySubscribers(path, value, oldValue);

    // Broadcast change
    if (!options.silent) {
      this.broadcastUpdate(path, value, oldValue);
    }

    // Persist state
    if (this.options.persistState) {
      this.persistState();
    }
  }

  // Update multiple values
  update(updates, options = {}) {
    const changes = [];

    Object.entries(updates).forEach(([path, value]) => {
      const oldValue = this.get(path);
      if (oldValue !== value) {
        this.setValueByPath(this.state, path, value);
        changes.push({ path, value, oldValue });
      }
    });

    if (changes.length > 0) {
      this.stateVersion++;

      // Notify subscribers
      changes.forEach(({ path, value, oldValue }) => {
        this.notifySubscribers(path, value, oldValue);
      });

      // Broadcast changes
      if (!options.silent) {
        this.channel.send('state-update', {
          changes,
          version: this.stateVersion,
          timestamp: Date.now(),
        });
      }

      // Persist state
      if (this.options.persistState) {
        this.persistState();
      }
    }
  }

  // Subscribe to state changes
  subscribe(path, callback) {
    if (!this.subscribers.has(path)) {
      this.subscribers.set(path, new Set());
    }

    this.subscribers.get(path).add(callback);

    // Call with current value
    callback(this.get(path), undefined);

    // Return unsubscribe function
    return () => {
      const callbacks = this.subscribers.get(path);
      if (callbacks) {
        callbacks.delete(callback);
        if (callbacks.size === 0) {
          this.subscribers.delete(path);
        }
      }
    };
  }

  // Handle remote update
  handleRemoteUpdate(data) {
    const { changes, version, timestamp } = data;

    // Check for conflicts
    const conflicts = changes.filter(({ path }) => this.localChanges.has(path));

    if (conflicts.length > 0) {
      this.resolveConflicts(conflicts, data);
    } else {
      // Apply changes
      changes.forEach(({ path, value, oldValue }) => {
        this.set(path, value, { silent: true });
      });
    }
  }

  // Handle sync request
  handleSyncRequest(message) {
    this.channel.respond(message, {
      state: this.state,
      version: this.stateVersion,
      timestamp: Date.now(),
    });
  }

  // Handle sync response
  handleSyncResponse(data) {
    const { state, version, timestamp } = data;

    // Merge with local state
    if (version > this.stateVersion || Object.keys(this.state).length === 0) {
      this.state = { ...state };
      this.stateVersion = version;

      // Notify all subscribers
      this.notifyAllSubscribers();

      // Persist merged state
      if (this.options.persistState) {
        this.persistState();
      }
    }
  }

  // Resolve conflicts
  resolveConflicts(conflicts, remoteData) {
    switch (this.options.conflictResolution) {
      case 'last-write-wins':
        // Apply remote changes
        conflicts.forEach(({ path, value }) => {
          this.set(path, value, { silent: true });
        });
        break;

      case 'local-wins':
        // Keep local changes, broadcast them
        conflicts.forEach(({ path }) => {
          const value = this.get(path);
          this.broadcastUpdate(path, value);
        });
        break;

      case 'merge':
        // Custom merge logic
        conflicts.forEach(({ path, value }) => {
          const localValue = this.get(path);
          const mergedValue = this.mergeValues(localValue, value);
          this.set(path, mergedValue);
        });
        break;

      case 'manual':
        // Emit conflict event for manual resolution
        this.channel.emit('state-conflict', {
          conflicts,
          localVersion: this.stateVersion,
          remoteVersion: remoteData.version,
        });
        break;
    }

    // Clear local changes
    conflicts.forEach(({ path }) => {
      this.localChanges.delete(path);
    });
  }

  // Merge values (for arrays and objects)
  mergeValues(local, remote) {
    if (Array.isArray(local) && Array.isArray(remote)) {
      // Merge arrays (remove duplicates)
      return [...new Set([...local, ...remote])];
    } else if (typeof local === 'object' && typeof remote === 'object') {
      // Merge objects
      return { ...local, ...remote };
    } else {
      // For primitives, use remote value
      return remote;
    }
  }

  // Broadcast state update
  broadcastUpdate(path, value, oldValue) {
    // Debounce broadcasts
    clearTimeout(this.broadcastTimeout);

    if (!this.pendingBroadcasts) {
      this.pendingBroadcasts = [];
    }

    this.pendingBroadcasts.push({ path, value, oldValue });

    this.broadcastTimeout = setTimeout(() => {
      this.channel.send('state-update', {
        changes: this.pendingBroadcasts,
        version: this.stateVersion,
        timestamp: Date.now(),
      });

      this.pendingBroadcasts = [];
      this.localChanges.clear();
    }, this.options.debounceDelay);
  }

  // Request state sync
  requestStateSync() {
    this.channel.send('state-sync-request', {
      version: this.stateVersion,
    });
  }

  // Notify subscribers
  notifySubscribers(path, value, oldValue) {
    // Exact path subscribers
    const exactSubscribers = this.subscribers.get(path);
    if (exactSubscribers) {
      exactSubscribers.forEach((callback) => {
        try {
          callback(value, oldValue);
        } catch (error) {
          console.error('Subscriber error:', error);
        }
      });
    }

    // Wildcard subscribers (e.g., 'user.*')
    this.subscribers.forEach((callbacks, subscriberPath) => {
      if (subscriberPath.includes('*')) {
        const regex = new RegExp(
          '^' + subscriberPath.replace(/\*/g, '.*') + '$'
        );

        if (regex.test(path)) {
          callbacks.forEach((callback) => {
            try {
              callback(value, oldValue, path);
            } catch (error) {
              console.error('Subscriber error:', error);
            }
          });
        }
      }
    });
  }

  // Notify all subscribers
  notifyAllSubscribers() {
    this.subscribers.forEach((callbacks, path) => {
      const value = this.get(path);
      callbacks.forEach((callback) => {
        try {
          callback(value, undefined);
        } catch (error) {
          console.error('Subscriber error:', error);
        }
      });
    });
  }

  // Get value by path
  getValueByPath(obj, path) {
    const parts = path.split('.');
    let current = obj;

    for (const part of parts) {
      if (current === null || current === undefined) {
        return undefined;
      }
      current = current[part];
    }

    return current;
  }

  // Set value by path
  setValueByPath(obj, path, value) {
    const parts = path.split('.');
    const last = parts.pop();
    let current = obj;

    for (const part of parts) {
      if (!(part in current)) {
        current[part] = {};
      }
      current = current[part];
    }

    current[last] = value;
  }

  // Persist state to localStorage
  persistState() {
    try {
      localStorage.setItem(
        `cross-tab-state-${this.options.channelName}`,
        JSON.stringify({
          state: this.state,
          version: this.stateVersion,
          timestamp: Date.now(),
        })
      );
    } catch (error) {
      console.error('Failed to persist state:', error);
    }
  }

  // Load persisted state
  loadPersistedState() {
    try {
      const stored = localStorage.getItem(
        `cross-tab-state-${this.options.channelName}`
      );

      if (stored) {
        const { state, version } = JSON.parse(stored);
        this.state = state;
        this.stateVersion = version;
      }
    } catch (error) {
      console.error('Failed to load persisted state:', error);
    }
  }

  // Clear state
  clear() {
    this.state = {};
    this.stateVersion = 0;
    this.localChanges.clear();

    // Notify subscribers
    this.subscribers.forEach((callbacks, path) => {
      callbacks.forEach((callback) => {
        try {
          callback(undefined, this.get(path));
        } catch (error) {
          console.error('Subscriber error:', error);
        }
      });
    });

    // Broadcast clear
    this.channel.send('state-update', {
      changes: [{ path: '*', value: undefined }],
      version: this.stateVersion,
      timestamp: Date.now(),
    });

    // Clear persisted state
    if (this.options.persistState) {
      localStorage.removeItem(`cross-tab-state-${this.options.channelName}`);
    }
  }
}

// Usage
const stateManager = new CrossTabStateManager({
  channelName: 'app-state',
  conflictResolution: 'last-write-wins',
  persistState: true,
});

// Subscribe to state changes
stateManager.subscribe('user.name', (name) => {
  console.log('User name changed:', name);
  document.getElementById('username').textContent = name || 'Guest';
});

// Subscribe with wildcard
stateManager.subscribe('settings.*', (value, oldValue, path) => {
  console.log(`Setting ${path} changed:`, value);
});

// Update state
stateManager.set('user.name', 'John Doe');
stateManager.set('user.preferences.theme', 'dark');

// Batch updates
stateManager.update({
  'user.name': 'Jane Doe',
  'user.email': 'jane@example.com',
  'settings.notifications': true,
});

Event Broadcasting

Event Bus Implementation

class BroadcastEventBus {
  constructor(channelName = 'event-bus') {
    this.channel = new ChannelManager(channelName);
    this.localListeners = new Map();
    this.eventHistory = [];
    this.maxHistorySize = 100;
    this.init();
  }

  // Initialize event bus
  init() {
    // Handle incoming events
    this.channel.on('event', (data) => {
      this.handleRemoteEvent(data);
    });

    // Handle event history requests
    this.channel.on('history-request', (data, message) => {
      this.handleHistoryRequest(data, message);
    });

    // Handle event history response
    this.channel.on('history-response', (data) => {
      this.handleHistoryResponse(data);
    });

    // Request history on startup
    this.requestHistory();
  }

  // Emit event
  emit(eventName, data = {}, options = {}) {
    const event = {
      id: this.generateEventId(),
      name: eventName,
      data,
      timestamp: Date.now(),
      origin: window.location.href,
      ...options,
    };

    // Add to history
    this.addToHistory(event);

    // Trigger local listeners
    this.triggerLocalListeners(event);

    // Broadcast to other contexts
    if (!options.local) {
      this.channel.send('event', event);
    }

    return event;
  }

  // Listen for events
  on(eventName, callback, options = {}) {
    const listener = {
      callback,
      options,
      eventName,
    };

    if (!this.localListeners.has(eventName)) {
      this.localListeners.set(eventName, new Set());
    }

    this.localListeners.get(eventName).add(listener);

    // Return unsubscribe function
    return () => {
      const listeners = this.localListeners.get(eventName);
      if (listeners) {
        listeners.delete(listener);
        if (listeners.size === 0) {
          this.localListeners.delete(eventName);
        }
      }
    };
  }

  // Once listener
  once(eventName, callback, options = {}) {
    const unsubscribe = this.on(
      eventName,
      (...args) => {
        unsubscribe();
        callback(...args);
      },
      options
    );

    return unsubscribe;
  }

  // Emit and wait for response
  request(eventName, data = {}, timeout = 5000) {
    return new Promise((resolve, reject) => {
      const requestId = this.generateEventId();
      const responseEvent = `${eventName}:response`;

      // Setup response listener
      const cleanup = this.once(responseEvent, (responseData) => {
        if (responseData.requestId === requestId) {
          clearTimeout(timeoutId);
          resolve(responseData);
        }
      });

      // Setup timeout
      const timeoutId = setTimeout(() => {
        cleanup();
        reject(new Error(`Request timeout: ${eventName}`));
      }, timeout);

      // Emit request
      this.emit(eventName, { ...data, requestId });
    });
  }

  // Reply to request
  reply(originalEvent, responseData) {
    const responseEvent = `${originalEvent.name}:response`;

    this.emit(responseEvent, {
      ...responseData,
      requestId: originalEvent.data.requestId,
    });
  }

  // Handle remote event
  handleRemoteEvent(event) {
    // Add to history
    this.addToHistory(event);

    // Trigger local listeners
    this.triggerLocalListeners(event);
  }

  // Trigger local listeners
  triggerLocalListeners(event) {
    // Exact match listeners
    const exactListeners = this.localListeners.get(event.name);
    if (exactListeners) {
      exactListeners.forEach((listener) => {
        this.invokeListener(listener, event);
      });
    }

    // Wildcard listeners
    this.localListeners.forEach((listeners, pattern) => {
      if (pattern.includes('*')) {
        const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');

        if (regex.test(event.name)) {
          listeners.forEach((listener) => {
            this.invokeListener(listener, event);
          });
        }
      }
    });

    // Global listeners
    const globalListeners = this.localListeners.get('*');
    if (globalListeners) {
      globalListeners.forEach((listener) => {
        this.invokeListener(listener, event);
      });
    }
  }

  // Invoke listener safely
  invokeListener(listener, event) {
    try {
      if (listener.options.filter) {
        if (!listener.options.filter(event)) {
          return;
        }
      }

      listener.callback(event.data, event);
    } catch (error) {
      console.error(`Event listener error for ${event.name}:`, error);
    }
  }

  // Add event to history
  addToHistory(event) {
    this.eventHistory.push(event);

    // Limit history size
    if (this.eventHistory.length > this.maxHistorySize) {
      this.eventHistory = this.eventHistory.slice(-this.maxHistorySize);
    }
  }

  // Request event history
  requestHistory(since = 0) {
    this.channel.send('history-request', { since });
  }

  // Handle history request
  handleHistoryRequest(data, message) {
    const { since = 0 } = data;
    const history = this.eventHistory.filter(
      (event) => event.timestamp > since
    );

    this.channel.respond(message, { history });
  }

  // Handle history response
  handleHistoryResponse(data) {
    const { history = [] } = data;

    // Merge history
    history.forEach((event) => {
      if (!this.eventHistory.find((e) => e.id === event.id)) {
        this.addToHistory(event);

        // Replay events
        if (this.options.replayHistory) {
          this.triggerLocalListeners(event);
        }
      }
    });

    // Sort history by timestamp
    this.eventHistory.sort((a, b) => a.timestamp - b.timestamp);
  }

  // Generate unique event ID
  generateEventId() {
    return `evt-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
  }

  // Get event history
  getHistory(filter = {}) {
    let history = [...this.eventHistory];

    // Filter by event name
    if (filter.name) {
      history = history.filter((event) => event.name === filter.name);
    }

    // Filter by time range
    if (filter.since) {
      history = history.filter((event) => event.timestamp > filter.since);
    }

    if (filter.until) {
      history = history.filter((event) => event.timestamp < filter.until);
    }

    return history;
  }

  // Clear history
  clearHistory() {
    this.eventHistory = [];
  }

  // Destroy event bus
  destroy() {
    this.localListeners.clear();
    this.eventHistory = [];
    this.channel.close();
  }
}

// Usage
const eventBus = new BroadcastEventBus('app-events');

// Listen for events
eventBus.on('user:login', (data) => {
  console.log('User logged in:', data.username);
  updateUIForLoggedInUser(data);
});

// Listen with wildcard
eventBus.on('user:*', (data, event) => {
  console.log(`User event ${event.name}:`, data);
});

// Emit events
eventBus.emit('user:login', {
  username: 'john_doe',
  timestamp: Date.now(),
});

// Request-response pattern
eventBus.on('data:fetch', async (data, event) => {
  const result = await fetchData(data.query);
  eventBus.reply(event, { result });
});

// Make request
eventBus
  .request('data:fetch', { query: 'users' })
  .then((response) => console.log('Data:', response.result))
  .catch((error) => console.error('Request failed:', error));

Real-time Collaboration

Collaborative Editor

class CollaborativeEditor {
  constructor(editorElement, options = {}) {
    this.editor = editorElement;
    this.options = {
      channelName: 'collab-editor',
      userId: this.generateUserId(),
      userName: 'Anonymous',
      cursorColors: ['#FF6B6B', '#4ECDC4', '#45B7D1', '#F7DC6F'],
      ...options,
    };

    this.channel = new ChannelManager(this.options.channelName);
    this.collaborators = new Map();
    this.localVersion = 0;
    this.remoteVersion = 0;

    this.init();
  }

  // Initialize collaborative editor
  init() {
    this.setupEditor();
    this.setupChannelHandlers();
    this.announcePresence();

    // Setup cleanup
    window.addEventListener('beforeunload', () => {
      this.leave();
    });
  }

  // Setup editor
  setupEditor() {
    // Track local changes
    this.editor.addEventListener('input', () => {
      this.handleLocalChange();
    });

    // Track selection changes
    document.addEventListener('selectionchange', () => {
      if (this.editor.contains(document.activeElement)) {
        this.handleSelectionChange();
      }
    });

    // Create cursor container
    this.cursorContainer = document.createElement('div');
    this.cursorContainer.className = 'cursor-container';
    this.editor.parentElement.style.position = 'relative';
    this.editor.parentElement.appendChild(this.cursorContainer);

    this.applyCursorStyles();
  }

  // Setup channel handlers
  setupChannelHandlers() {
    // Handle text changes
    this.channel.on('text:change', (data) => {
      this.handleRemoteChange(data);
    });

    // Handle cursor updates
    this.channel.on('cursor:update', (data) => {
      this.handleCursorUpdate(data);
    });

    // Handle presence updates
    this.channel.on('presence:update', (data) => {
      this.handlePresenceUpdate(data);
    });

    // Handle sync requests
    this.channel.on('sync:request', (data, message) => {
      this.handleSyncRequest(message);
    });

    // Handle sync responses
    this.channel.on('sync:response', (data) => {
      this.handleSyncResponse(data);
    });
  }

  // Handle local text change
  handleLocalChange() {
    const content = this.editor.value || this.editor.textContent;
    const change = this.calculateChange(this.lastContent || '', content);

    if (change) {
      this.localVersion++;
      this.lastContent = content;

      // Broadcast change
      this.channel.send('text:change', {
        change,
        version: this.localVersion,
        userId: this.options.userId,
      });
    }
  }

  // Calculate change delta
  calculateChange(oldText, newText) {
    // Simple diff algorithm (in production, use a proper diff library)
    const commonStart = this.getCommonStart(oldText, newText);
    const commonEnd = this.getCommonEnd(
      oldText.slice(commonStart),
      newText.slice(commonStart)
    );

    const deleteLength = oldText.length - commonStart - commonEnd;
    const insertText = newText.slice(commonStart, newText.length - commonEnd);

    if (deleteLength === 0 && insertText.length === 0) {
      return null;
    }

    return {
      position: commonStart,
      deleteLength,
      insertText,
    };
  }

  // Get common start length
  getCommonStart(a, b) {
    let i = 0;
    while (i < a.length && i < b.length && a[i] === b[i]) {
      i++;
    }
    return i;
  }

  // Get common end length
  getCommonEnd(a, b) {
    let i = 0;
    while (
      i < a.length &&
      i < b.length &&
      a[a.length - 1 - i] === b[b.length - 1 - i]
    ) {
      i++;
    }
    return i;
  }

  // Handle remote text change
  handleRemoteChange(data) {
    const { change, version, userId } = data;

    if (userId === this.options.userId) {
      return; // Ignore own changes
    }

    // Apply operational transformation if needed
    const transformedChange = this.transformChange(change);

    // Apply change
    this.applyChange(transformedChange);

    // Update version
    this.remoteVersion = Math.max(this.remoteVersion, version);
  }

  // Transform change (simplified OT)
  transformChange(change) {
    // In a real implementation, this would handle conflicts
    // between concurrent edits
    return change;
  }

  // Apply change to editor
  applyChange(change) {
    const { position, deleteLength, insertText } = change;
    const content = this.editor.value || this.editor.textContent;

    const newContent =
      content.slice(0, position) +
      insertText +
      content.slice(position + deleteLength);

    // Update editor
    if (this.editor.value !== undefined) {
      const selectionStart = this.editor.selectionStart;
      const selectionEnd = this.editor.selectionEnd;

      this.editor.value = newContent;

      // Restore selection
      if (selectionStart > position) {
        const delta = insertText.length - deleteLength;
        this.editor.setSelectionRange(
          selectionStart + delta,
          selectionEnd + delta
        );
      }
    } else {
      this.editor.textContent = newContent;
    }

    this.lastContent = newContent;
  }

  // Handle selection change
  handleSelectionChange() {
    const selection = window.getSelection();

    if (selection.rangeCount > 0) {
      const range = selection.getRangeAt(0);
      const position = this.getCaretPosition();

      // Broadcast cursor position
      this.channel.send('cursor:update', {
        userId: this.options.userId,
        position,
        userName: this.options.userName,
      });
    }
  }

  // Get caret position
  getCaretPosition() {
    if (this.editor.selectionStart !== undefined) {
      return this.editor.selectionStart;
    }

    // For contenteditable
    const selection = window.getSelection();
    if (selection.rangeCount > 0) {
      const range = selection.getRangeAt(0);
      const preCaretRange = range.cloneRange();
      preCaretRange.selectNodeContents(this.editor);
      preCaretRange.setEnd(range.endContainer, range.endOffset);
      return preCaretRange.toString().length;
    }

    return 0;
  }

  // Handle cursor update
  handleCursorUpdate(data) {
    const { userId, position, userName } = data;

    if (userId === this.options.userId) {
      return; // Ignore own cursor
    }

    // Update collaborator cursor
    this.updateCollaboratorCursor(userId, position, userName);
  }

  // Update collaborator cursor
  updateCollaboratorCursor(userId, position, userName) {
    let collaborator = this.collaborators.get(userId);

    if (!collaborator) {
      // Create new collaborator
      const colorIndex =
        this.collaborators.size % this.options.cursorColors.length;
      collaborator = {
        userId,
        userName,
        color: this.options.cursorColors[colorIndex],
        cursor: this.createCursorElement(
          userName,
          this.options.cursorColors[colorIndex]
        ),
      };

      this.collaborators.set(userId, collaborator);
      this.cursorContainer.appendChild(collaborator.cursor);
    }

    // Update cursor position
    this.positionCursor(collaborator.cursor, position);

    // Reset hide timer
    clearTimeout(collaborator.hideTimer);
    collaborator.cursor.style.opacity = '1';

    collaborator.hideTimer = setTimeout(() => {
      collaborator.cursor.style.opacity = '0';
    }, 5000);
  }

  // Create cursor element
  createCursorElement(userName, color) {
    const cursor = document.createElement('div');
    cursor.className = 'collaborator-cursor';
    cursor.style.borderLeftColor = color;

    const label = document.createElement('div');
    label.className = 'cursor-label';
    label.textContent = userName;
    label.style.backgroundColor = color;

    cursor.appendChild(label);
    return cursor;
  }

  // Position cursor
  positionCursor(cursorElement, position) {
    // Calculate position based on text position
    // This is simplified - real implementation would be more complex
    const text = this.editor.value || this.editor.textContent;
    const beforeText = text.slice(0, position);
    const lines = beforeText.split('\n');

    const lineHeight = parseInt(
      window.getComputedStyle(this.editor).lineHeight
    );
    const top = (lines.length - 1) * lineHeight;

    cursorElement.style.top = `${top}px`;
    cursorElement.style.left = '0px'; // Simplified
  }

  // Announce presence
  announcePresence() {
    this.channel.send('presence:update', {
      userId: this.options.userId,
      userName: this.options.userName,
      action: 'join',
    });

    // Request sync
    this.channel.send('sync:request', {
      version: this.localVersion,
    });
  }

  // Handle presence update
  handlePresenceUpdate(data) {
    const { userId, userName, action } = data;

    if (action === 'join' && userId !== this.options.userId) {
      console.log(`${userName} joined the session`);

      // Send current state to new user
      this.channel.send('sync:response', {
        content: this.editor.value || this.editor.textContent,
        version: this.localVersion,
        targetUserId: userId,
      });
    } else if (action === 'leave') {
      // Remove collaborator
      const collaborator = this.collaborators.get(userId);
      if (collaborator) {
        collaborator.cursor.remove();
        this.collaborators.delete(userId);
      }

      console.log(`${userName} left the session`);
    }
  }

  // Handle sync request
  handleSyncRequest(message) {
    this.channel.respond(message, {
      content: this.editor.value || this.editor.textContent,
      version: this.localVersion,
    });
  }

  // Handle sync response
  handleSyncResponse(data) {
    const { content, version, targetUserId } = data;

    // Only process if targeted to us or broadcast
    if (targetUserId && targetUserId !== this.options.userId) {
      return;
    }

    // Update content if newer
    if (version > this.localVersion) {
      if (this.editor.value !== undefined) {
        this.editor.value = content;
      } else {
        this.editor.textContent = content;
      }

      this.lastContent = content;
      this.localVersion = version;
    }
  }

  // Apply cursor styles
  applyCursorStyles() {
    const style = document.createElement('style');
    style.textContent = `
      .cursor-container {
        position: absolute;
        top: 0;
        left: 0;
        pointer-events: none;
        z-index: 1000;
      }

      .collaborator-cursor {
        position: absolute;
        width: 2px;
        height: 1.2em;
        border-left: 2px solid;
        transition: opacity 0.3s, top 0.1s, left 0.1s;
      }

      .cursor-label {
        position: absolute;
        top: -20px;
        left: -2px;
        padding: 2px 6px;
        color: white;
        font-size: 12px;
        border-radius: 3px;
        white-space: nowrap;
        user-select: none;
      }
    `;

    document.head.appendChild(style);
  }

  // Leave session
  leave() {
    this.channel.send('presence:update', {
      userId: this.options.userId,
      userName: this.options.userName,
      action: 'leave',
    });

    this.channel.close();
  }

  // Generate user ID
  generateUserId() {
    return `user-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
  }
}

// Usage
const editor = document.getElementById('collaborative-editor');
const collabEditor = new CollaborativeEditor(editor, {
  userName: 'John Doe',
  channelName: 'doc-123',
});

// You can also use it with contenteditable
const richEditor = document.querySelector('[contenteditable]');
const richCollabEditor = new CollaborativeEditor(richEditor, {
  userName: 'Jane Smith',
  channelName: 'doc-456',
});

Best Practices

  1. Always check for API support

    if ('BroadcastChannel' in window) {
      // Use BroadcastChannel
    } else {
      // Use fallback (localStorage events, etc.)
    }
    
  2. Handle connection errors gracefully

    try {
      const channel = new BroadcastChannel('my-channel');
    } catch (error) {
      console.error('Failed to create channel:', error);
      // Use alternative communication method
    }
    
  3. Close channels when done

    window.addEventListener('beforeunload', () => {
      channel.close();
    });
    
  4. Implement message validation

    channel.onmessage = (event) => {
      if (isValidMessage(event.data)) {
        processMessage(event.data);
      }
    };
    

Browser Compatibility

The Broadcast Channel API has good support in modern browsers, but you should provide fallbacks:

class BroadcastChannelPolyfill {
  constructor(name) {
    this.name = name;
    this.id = Math.random().toString(36).substr(2, 9);
    this.listeners = [];

    // Use localStorage events as fallback
    window.addEventListener('storage', (e) => {
      if (e.key === `broadcast-${this.name}` && e.newValue) {
        try {
          const data = JSON.parse(e.newValue);
          if (data.id !== this.id) {
            const event = { data: data.message };
            this.listeners.forEach((listener) => listener(event));
          }
        } catch (error) {
          console.error('Failed to parse message:', error);
        }
      }
    });
  }

  postMessage(message) {
    try {
      localStorage.setItem(
        `broadcast-${this.name}`,
        JSON.stringify({
          id: this.id,
          message,
          timestamp: Date.now(),
        })
      );
    } catch (error) {
      console.error('Failed to send message:', error);
    }
  }

  set onmessage(handler) {
    this.listeners = [handler];
  }

  addEventListener(type, handler) {
    if (type === 'message') {
      this.listeners.push(handler);
    }
  }

  removeEventListener(type, handler) {
    if (type === 'message') {
      const index = this.listeners.indexOf(handler);
      if (index > -1) {
        this.listeners.splice(index, 1);
      }
    }
  }

  close() {
    this.listeners = [];
  }
}

// Use polyfill if needed
if (!('BroadcastChannel' in window)) {
  window.BroadcastChannel = BroadcastChannelPolyfill;
}

Conclusion

The Broadcast Channel API enables powerful cross-context communication:

  • State synchronization across tabs and windows
  • Real-time collaboration features
  • Event broadcasting for distributed applications
  • Shared notifications and alerts
  • Session management across contexts
  • Offline-first architectures

Key takeaways:

  • Simple API for complex communication needs
  • Same-origin security built-in
  • No server required for local communication
  • Efficient message broadcasting
  • Great for multi-tab applications
  • Consider fallbacks for older browsers

Build sophisticated multi-context applications with seamless communication!