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