Real-Time Collaboration WebSockets Development

Building Real-Time Collaboration Features in Browser Extensions

E
Extendable Team
· 15 min read

Real-time collaboration transforms browser extensions from single-user tools into shared experiences. Whether it’s collaborative annotations, shared bookmarks, or synchronized browsing, real-time features can dramatically increase engagement and value. This guide covers implementing collaboration features from WebSocket connections to conflict resolution.

Collaboration Architecture

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│  User A's   │     │  User B's   │     │  User C's   │
│  Extension  │     │  Extension  │     │  Extension  │
└──────┬──────┘     └──────┬──────┘     └──────┬──────┘
       │                   │                   │
       └───────────────────┼───────────────────┘

                    ┌──────▼──────┐
                    │  WebSocket  │
                    │   Server    │
                    └──────┬──────┘

                    ┌──────▼──────┐
                    │  Database   │
                    │  (State)    │
                    └─────────────┘
Architecture Choices:
  • WebSocket: Real-time bidirectional communication
  • Server-Sent Events: One-way updates (simpler)
  • WebRTC: Peer-to-peer (complex, lower latency)
  • Polling: Simplest, but higher latency

WebSocket Connection Management

Client Implementation

// websocket-client.js
class CollaborationClient {
  constructor(serverUrl, options = {}) {
    this.serverUrl = serverUrl;
    this.options = options;
    this.ws = null;
    this.reconnectAttempts = 0;
    this.maxReconnectAttempts = 10;
    this.reconnectDelay = 1000;
    this.messageHandlers = new Map();
    this.pendingMessages = [];
  }

  async connect(authToken) {
    return new Promise((resolve, reject) => {
      const url = new URL(this.serverUrl);
      url.searchParams.set('token', authToken);

      this.ws = new WebSocket(url.toString());

      this.ws.onopen = () => {
        console.log('WebSocket connected');
        this.reconnectAttempts = 0;
        this.flushPendingMessages();
        resolve();
      };

      this.ws.onclose = (event) => {
        console.log('WebSocket closed:', event.code, event.reason);
        this.handleDisconnect();
      };

      this.ws.onerror = (error) => {
        console.error('WebSocket error:', error);
        reject(error);
      };

      this.ws.onmessage = (event) => {
        this.handleMessage(JSON.parse(event.data));
      };
    });
  }

  send(type, payload) {
    const message = { type, payload, timestamp: Date.now() };

    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(message));
    } else {
      this.pendingMessages.push(message);
    }
  }

  on(type, handler) {
    if (!this.messageHandlers.has(type)) {
      this.messageHandlers.set(type, []);
    }
    this.messageHandlers.get(type).push(handler);
  }

  off(type, handler) {
    const handlers = this.messageHandlers.get(type);
    if (handlers) {
      const index = handlers.indexOf(handler);
      if (index !== -1) handlers.splice(index, 1);
    }
  }

  handleMessage(message) {
    const handlers = this.messageHandlers.get(message.type);
    if (handlers) {
      handlers.forEach(handler => handler(message.payload));
    }
  }

  handleDisconnect() {
    if (this.reconnectAttempts < this.maxReconnectAttempts) {
      const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts);
      console.log(`Reconnecting in ${delay}ms...`);

      setTimeout(() => {
        this.reconnectAttempts++;
        this.connect(this.lastAuthToken).catch(console.error);
      }, delay);
    }
  }

  flushPendingMessages() {
    while (this.pendingMessages.length > 0) {
      const message = this.pendingMessages.shift();
      this.ws.send(JSON.stringify(message));
    }
  }

  disconnect() {
    if (this.ws) {
      this.ws.close(1000, 'Client disconnect');
      this.ws = null;
    }
  }
}

Server Implementation (Node.js)

// server.js
const WebSocket = require('ws');
const jwt = require('jsonwebtoken');

class CollaborationServer {
  constructor(port) {
    this.wss = new WebSocket.Server({ port });
    this.rooms = new Map();
    this.clients = new Map();

    this.wss.on('connection', this.handleConnection.bind(this));
  }

  handleConnection(ws, request) {
    const url = new URL(request.url, `http://${request.headers.host}`);
    const token = url.searchParams.get('token');

    try {
      const user = jwt.verify(token, process.env.JWT_SECRET);
      ws.userId = user.id;
      ws.user = user;

      this.clients.set(user.id, ws);

      ws.on('message', (data) => this.handleMessage(ws, JSON.parse(data)));
      ws.on('close', () => this.handleDisconnect(ws));

      this.send(ws, 'connected', { userId: user.id });

    } catch (error) {
      ws.close(4001, 'Authentication failed');
    }
  }

  handleMessage(ws, message) {
    switch (message.type) {
      case 'join_room':
        this.joinRoom(ws, message.payload.roomId);
        break;
      case 'leave_room':
        this.leaveRoom(ws, message.payload.roomId);
        break;
      case 'broadcast':
        this.broadcast(ws, message.payload);
        break;
      case 'presence':
        this.updatePresence(ws, message.payload);
        break;
      case 'operation':
        this.handleOperation(ws, message.payload);
        break;
    }
  }

  joinRoom(ws, roomId) {
    if (!this.rooms.has(roomId)) {
      this.rooms.set(roomId, new Set());
    }

    const room = this.rooms.get(roomId);
    room.add(ws.userId);
    ws.roomId = roomId;

    // Notify others
    this.broadcastToRoom(roomId, 'user_joined', {
      userId: ws.userId,
      user: ws.user
    }, ws.userId);

    // Send current room state
    this.send(ws, 'room_state', {
      roomId,
      members: Array.from(room).map(id => this.clients.get(id)?.user).filter(Boolean)
    });
  }

  leaveRoom(ws, roomId) {
    const room = this.rooms.get(roomId);
    if (room) {
      room.delete(ws.userId);
      this.broadcastToRoom(roomId, 'user_left', { userId: ws.userId }, ws.userId);

      if (room.size === 0) {
        this.rooms.delete(roomId);
      }
    }
  }

  broadcast(ws, payload) {
    if (ws.roomId) {
      this.broadcastToRoom(ws.roomId, 'broadcast', {
        from: ws.userId,
        ...payload
      }, ws.userId);
    }
  }

  broadcastToRoom(roomId, type, payload, excludeUserId = null) {
    const room = this.rooms.get(roomId);
    if (!room) return;

    room.forEach(userId => {
      if (userId !== excludeUserId) {
        const client = this.clients.get(userId);
        if (client) {
          this.send(client, type, payload);
        }
      }
    });
  }

  send(ws, type, payload) {
    if (ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify({ type, payload }));
    }
  }

  handleDisconnect(ws) {
    if (ws.roomId) {
      this.leaveRoom(ws, ws.roomId);
    }
    this.clients.delete(ws.userId);
  }
}

Presence Indicators

Show who’s currently active:

// presence.js
class PresenceManager {
  constructor(client) {
    this.client = client;
    this.users = new Map();
    this.updateInterval = null;

    client.on('user_joined', (data) => this.handleUserJoined(data));
    client.on('user_left', (data) => this.handleUserLeft(data));
    client.on('presence_update', (data) => this.handlePresenceUpdate(data));
    client.on('room_state', (data) => this.handleRoomState(data));
  }

  startHeartbeat(interval = 30000) {
    this.updateInterval = setInterval(() => {
      this.client.send('presence', {
        status: 'active',
        cursor: this.currentCursor,
        lastActivity: Date.now()
      });
    }, interval);
  }

  stopHeartbeat() {
    if (this.updateInterval) {
      clearInterval(this.updateInterval);
      this.updateInterval = null;
    }
  }

  updateCursor(position) {
    this.currentCursor = position;
    this.client.send('presence', {
      status: 'active',
      cursor: position,
      lastActivity: Date.now()
    });
  }

  handleUserJoined(data) {
    this.users.set(data.userId, {
      ...data.user,
      status: 'active',
      joinedAt: Date.now()
    });
    this.emitChange();
  }

  handleUserLeft(data) {
    this.users.delete(data.userId);
    this.emitChange();
  }

  handlePresenceUpdate(data) {
    const user = this.users.get(data.userId);
    if (user) {
      Object.assign(user, data);
      this.emitChange();
    }
  }

  handleRoomState(data) {
    this.users.clear();
    data.members.forEach(member => {
      this.users.set(member.id, { ...member, status: 'active' });
    });
    this.emitChange();
  }

  emitChange() {
    this.onChange?.(Array.from(this.users.values()));
  }

  getActiveUsers() {
    return Array.from(this.users.values()).filter(u => u.status === 'active');
  }
}

// UI component for presence
function renderPresenceAvatars(users) {
  const container = document.getElementById('presence-avatars');
  container.innerHTML = '';

  users.slice(0, 5).forEach(user => {
    const avatar = document.createElement('div');
    avatar.className = 'presence-avatar';
    avatar.style.backgroundColor = stringToColor(user.id);
    avatar.textContent = user.name?.[0] || '?';
    avatar.title = user.name || 'Anonymous';

    if (user.cursor) {
      avatar.dataset.cursor = JSON.stringify(user.cursor);
    }

    container.appendChild(avatar);
  });

  if (users.length > 5) {
    const more = document.createElement('div');
    more.className = 'presence-more';
    more.textContent = `+${users.length - 5}`;
    container.appendChild(more);
  }
}

function stringToColor(str) {
  let hash = 0;
  for (let i = 0; i < str.length; i++) {
    hash = str.charCodeAt(i) + ((hash << 5) - hash);
  }
  const hue = hash % 360;
  return `hsl(${hue}, 70%, 60%)`;
}
Presence Best Practices:
  • Show max 5-8 avatars, "+N more" for rest
  • Idle detection: Mark users away after 5 min
  • Graceful degradation: Work without presence
  • Cursor positions: Throttle to 50ms updates

Conflict Resolution with CRDTs

For concurrent edits, use Conflict-free Replicated Data Types:

// crdt.js - Simple LWW (Last-Writer-Wins) Register
class LWWRegister {
  constructor(nodeId) {
    this.nodeId = nodeId;
    this.value = null;
    this.timestamp = 0;
  }

  get() {
    return this.value;
  }

  set(value) {
    this.timestamp = Date.now();
    this.value = value;
    return { value, timestamp: this.timestamp, nodeId: this.nodeId };
  }

  merge(remote) {
    // Higher timestamp wins, nodeId as tiebreaker
    if (remote.timestamp > this.timestamp ||
        (remote.timestamp === this.timestamp && remote.nodeId > this.nodeId)) {
      this.value = remote.value;
      this.timestamp = remote.timestamp;
      return true; // Changed
    }
    return false; // No change
  }
}

// G-Counter (Grow-only counter)
class GCounter {
  constructor(nodeId) {
    this.nodeId = nodeId;
    this.counts = new Map();
  }

  get() {
    let total = 0;
    for (const count of this.counts.values()) {
      total += count;
    }
    return total;
  }

  increment(amount = 1) {
    const current = this.counts.get(this.nodeId) || 0;
    this.counts.set(this.nodeId, current + amount);
    return this.getState();
  }

  getState() {
    return Object.fromEntries(this.counts);
  }

  merge(remote) {
    for (const [nodeId, count] of Object.entries(remote)) {
      const current = this.counts.get(nodeId) || 0;
      this.counts.set(nodeId, Math.max(current, count));
    }
  }
}

// LWW-Map for collaborative key-value storage
class LWWMap {
  constructor(nodeId) {
    this.nodeId = nodeId;
    this.entries = new Map(); // key -> { value, timestamp, nodeId }
  }

  get(key) {
    return this.entries.get(key)?.value;
  }

  set(key, value) {
    const entry = {
      value,
      timestamp: Date.now(),
      nodeId: this.nodeId
    };
    this.entries.set(key, entry);
    return { key, ...entry };
  }

  delete(key) {
    return this.set(key, null); // Tombstone
  }

  merge(remoteEntry) {
    const { key, value, timestamp, nodeId } = remoteEntry;
    const local = this.entries.get(key);

    if (!local ||
        timestamp > local.timestamp ||
        (timestamp === local.timestamp && nodeId > local.nodeId)) {
      this.entries.set(key, { value, timestamp, nodeId });
      return true;
    }
    return false;
  }

  getAll() {
    const result = {};
    for (const [key, entry] of this.entries) {
      if (entry.value !== null) {
        result[key] = entry.value;
      }
    }
    return result;
  }
}

Collaborative Annotations Example

Putting it together for a shared annotation feature:

// annotations.js
class CollaborativeAnnotations {
  constructor(collabClient, roomId) {
    this.client = collabClient;
    this.roomId = roomId;
    this.annotations = new LWWMap(this.client.userId);
    this.presence = new PresenceManager(collabClient);

    this.setupListeners();
  }

  setupListeners() {
    this.client.on('annotation_added', (data) => {
      if (this.annotations.merge(data)) {
        this.renderAnnotation(data.key, data.value);
      }
    });

    this.client.on('annotation_removed', (data) => {
      if (this.annotations.merge(data)) {
        this.removeAnnotationUI(data.key);
      }
    });

    this.client.on('room_state', (data) => {
      // Sync initial state
      data.annotations?.forEach(a => {
        if (this.annotations.merge(a)) {
          this.renderAnnotation(a.key, a.value);
        }
      });
    });
  }

  async addAnnotation(selection, comment) {
    const id = `${this.client.userId}-${Date.now()}`;

    const annotation = {
      id,
      selection: {
        text: selection.toString(),
        range: this.serializeRange(selection.getRangeAt(0))
      },
      comment,
      author: {
        id: this.client.userId,
        name: this.client.userName
      },
      createdAt: Date.now()
    };

    const update = this.annotations.set(id, annotation);

    // Broadcast to room
    this.client.send('annotation_added', update);

    // Render locally
    this.renderAnnotation(id, annotation);

    return annotation;
  }

  async removeAnnotation(id) {
    const update = this.annotations.delete(id);
    this.client.send('annotation_removed', update);
    this.removeAnnotationUI(id);
  }

  renderAnnotation(id, annotation) {
    if (!annotation) return;

    // Highlight text
    const range = this.deserializeRange(annotation.selection.range);
    const highlight = document.createElement('mark');
    highlight.className = 'collab-annotation';
    highlight.dataset.annotationId = id;
    highlight.style.backgroundColor = stringToColor(annotation.author.id);

    range.surroundContents(highlight);

    // Add comment indicator
    const indicator = document.createElement('span');
    indicator.className = 'annotation-indicator';
    indicator.textContent = annotation.author.name?.[0] || '?';
    indicator.title = `${annotation.author.name}: ${annotation.comment}`;
    highlight.appendChild(indicator);
  }

  removeAnnotationUI(id) {
    const highlight = document.querySelector(`[data-annotation-id="${id}"]`);
    if (highlight) {
      const text = highlight.textContent;
      highlight.replaceWith(document.createTextNode(text));
    }
  }

  serializeRange(range) {
    return {
      startContainer: this.getXPath(range.startContainer),
      startOffset: range.startOffset,
      endContainer: this.getXPath(range.endContainer),
      endOffset: range.endOffset
    };
  }

  deserializeRange(serialized) {
    const range = document.createRange();
    range.setStart(
      this.getNodeByXPath(serialized.startContainer),
      serialized.startOffset
    );
    range.setEnd(
      this.getNodeByXPath(serialized.endContainer),
      serialized.endOffset
    );
    return range;
  }

  getXPath(node) {
    // Simplified XPath generation
    const parts = [];
    while (node && node !== document.body) {
      let index = 1;
      let sibling = node.previousSibling;
      while (sibling) {
        if (sibling.nodeType === node.nodeType &&
            sibling.nodeName === node.nodeName) {
          index++;
        }
        sibling = sibling.previousSibling;
      }
      parts.unshift(`${node.nodeName.toLowerCase()}[${index}]`);
      node = node.parentNode;
    }
    return '//' + parts.join('/');
  }

  getNodeByXPath(xpath) {
    return document.evaluate(
      xpath,
      document,
      null,
      XPathResult.FIRST_ORDERED_NODE_TYPE,
      null
    ).singleNodeValue;
  }
}

Summary

Real-time collaboration features can transform your extension’s value proposition. Key components include reliable WebSocket communication, presence awareness, and conflict resolution with CRDTs.

Implementation checklist:

  • WebSocket client with reconnection logic
  • Server-side room and user management
  • Presence indicators with heartbeat
  • CRDT-based conflict resolution
  • Optimistic UI updates
  • Graceful offline handling