Back to Blog
Real-TimeWebSocketsCollaborationCRDT

Real-Time Collaboration: Building Multiplayer Experiences

Implement real-time collaboration features. Learn WebSockets, CRDTs, and operational transforms for building collaborative applications.

B
Bootspring Team
Engineering
February 26, 2026
6 min read

Real-time collaboration enables multiple users to work together simultaneously. This guide covers the patterns and technologies for building collaborative features.

WebSocket Fundamentals#

Server Setup#

1import { WebSocketServer, WebSocket } from 'ws'; 2 3interface Client { 4 id: string; 5 ws: WebSocket; 6 room: string; 7} 8 9class CollaborationServer { 10 private wss: WebSocketServer; 11 private clients: Map<string, Client> = new Map(); 12 private rooms: Map<string, Set<string>> = new Map(); 13 14 constructor(port: number) { 15 this.wss = new WebSocketServer({ port }); 16 17 this.wss.on('connection', (ws) => { 18 const clientId = crypto.randomUUID(); 19 20 ws.on('message', (data) => { 21 const message = JSON.parse(data.toString()); 22 this.handleMessage(clientId, message); 23 }); 24 25 ws.on('close', () => { 26 this.removeClient(clientId); 27 }); 28 29 this.clients.set(clientId, { id: clientId, ws, room: '' }); 30 ws.send(JSON.stringify({ type: 'connected', clientId })); 31 }); 32 } 33 34 private handleMessage(clientId: string, message: any) { 35 switch (message.type) { 36 case 'join': 37 this.joinRoom(clientId, message.room); 38 break; 39 case 'broadcast': 40 this.broadcast(clientId, message.data); 41 break; 42 case 'cursor': 43 this.broadcastCursor(clientId, message.position); 44 break; 45 } 46 } 47 48 private joinRoom(clientId: string, room: string) { 49 const client = this.clients.get(clientId); 50 if (!client) return; 51 52 // Leave previous room 53 if (client.room) { 54 this.rooms.get(client.room)?.delete(clientId); 55 } 56 57 // Join new room 58 client.room = room; 59 if (!this.rooms.has(room)) { 60 this.rooms.set(room, new Set()); 61 } 62 this.rooms.get(room)!.add(clientId); 63 64 // Notify room members 65 this.broadcastToRoom(room, { 66 type: 'user-joined', 67 clientId, 68 }, clientId); 69 } 70 71 private broadcast(clientId: string, data: any) { 72 const client = this.clients.get(clientId); 73 if (!client?.room) return; 74 75 this.broadcastToRoom(client.room, { 76 type: 'update', 77 from: clientId, 78 data, 79 }, clientId); 80 } 81 82 private broadcastToRoom(room: string, message: any, excludeClient?: string) { 83 const roomClients = this.rooms.get(room); 84 if (!roomClients) return; 85 86 const payload = JSON.stringify(message); 87 for (const clientId of roomClients) { 88 if (clientId === excludeClient) continue; 89 this.clients.get(clientId)?.ws.send(payload); 90 } 91 } 92}

Client Connection#

1class CollaborationClient { 2 private ws: WebSocket; 3 private listeners: Map<string, Set<(data: any) => void>> = new Map(); 4 5 constructor(url: string) { 6 this.ws = new WebSocket(url); 7 8 this.ws.onmessage = (event) => { 9 const message = JSON.parse(event.data); 10 this.emit(message.type, message); 11 }; 12 } 13 14 join(room: string) { 15 this.send({ type: 'join', room }); 16 } 17 18 broadcast(data: any) { 19 this.send({ type: 'broadcast', data }); 20 } 21 22 on(event: string, callback: (data: any) => void) { 23 if (!this.listeners.has(event)) { 24 this.listeners.set(event, new Set()); 25 } 26 this.listeners.get(event)!.add(callback); 27 } 28 29 private send(message: any) { 30 this.ws.send(JSON.stringify(message)); 31 } 32 33 private emit(event: string, data: any) { 34 this.listeners.get(event)?.forEach(cb => cb(data)); 35 } 36}

Presence and Cursors#

Tracking User Presence#

1interface UserPresence { 2 id: string; 3 name: string; 4 color: string; 5 cursor: { x: number; y: number } | null; 6 selection: { start: number; end: number } | null; 7 lastSeen: number; 8} 9 10class PresenceManager { 11 private presence: Map<string, UserPresence> = new Map(); 12 private client: CollaborationClient; 13 14 constructor(client: CollaborationClient) { 15 this.client = client; 16 17 client.on('presence', (data) => { 18 this.presence.set(data.userId, data.presence); 19 this.onPresenceChange(); 20 }); 21 22 client.on('user-left', (data) => { 23 this.presence.delete(data.userId); 24 this.onPresenceChange(); 25 }); 26 27 // Heartbeat 28 setInterval(() => { 29 this.sendPresence(); 30 }, 1000); 31 } 32 33 updateCursor(position: { x: number; y: number }) { 34 this.sendPresence({ cursor: position }); 35 } 36 37 updateSelection(selection: { start: number; end: number }) { 38 this.sendPresence({ selection }); 39 } 40 41 private sendPresence(update?: Partial<UserPresence>) { 42 this.client.broadcast({ 43 type: 'presence', 44 ...update, 45 }); 46 } 47 48 getOtherUsers(): UserPresence[] { 49 return Array.from(this.presence.values()); 50 } 51 52 private onPresenceChange() { 53 // Trigger React re-render or update UI 54 } 55}

React Cursor Component#

1function CollaborativeCursors({ users }: { users: UserPresence[] }) { 2 return ( 3 <> 4 {users.map(user => ( 5 user.cursor && ( 6 <div 7 key={user.id} 8 className="absolute pointer-events-none transition-all duration-75" 9 style={{ 10 left: user.cursor.x, 11 top: user.cursor.y, 12 }} 13 > 14 <svg 15 width="24" 16 height="24" 17 viewBox="0 0 24 24" 18 fill={user.color} 19 > 20 <path d="M5.5 3.21V20.8l4.86-4.86h9.36L5.5 3.21z" /> 21 </svg> 22 <span 23 className="ml-2 px-2 py-1 rounded text-white text-xs" 24 style={{ backgroundColor: user.color }} 25 > 26 {user.name} 27 </span> 28 </div> 29 ) 30 ))} 31 </> 32 ); 33}

Conflict Resolution#

Operational Transform (OT)#

1interface Operation { 2 type: 'insert' | 'delete'; 3 position: number; 4 content?: string; 5 length?: number; 6} 7 8function transformOperation(op1: Operation, op2: Operation): Operation { 9 const transformed = { ...op1 }; 10 11 if (op1.position <= op2.position) { 12 // op1 comes before op2, no change needed 13 return transformed; 14 } 15 16 if (op2.type === 'insert') { 17 // Shift op1 position by inserted length 18 transformed.position += op2.content!.length; 19 } else if (op2.type === 'delete') { 20 // Shift op1 position by deleted length 21 transformed.position -= Math.min(op2.length!, op1.position - op2.position); 22 } 23 24 return transformed; 25} 26 27class OTDocument { 28 private content: string = ''; 29 private revision: number = 0; 30 private pendingOps: Operation[] = []; 31 32 applyLocal(op: Operation) { 33 this.content = this.apply(this.content, op); 34 this.pendingOps.push(op); 35 // Send to server 36 } 37 38 applyRemote(op: Operation, serverRevision: number) { 39 // Transform against pending ops 40 let transformed = op; 41 for (const pending of this.pendingOps) { 42 transformed = transformOperation(transformed, pending); 43 } 44 45 this.content = this.apply(this.content, transformed); 46 this.revision = serverRevision; 47 } 48 49 private apply(content: string, op: Operation): string { 50 if (op.type === 'insert') { 51 return content.slice(0, op.position) + 52 op.content + 53 content.slice(op.position); 54 } else { 55 return content.slice(0, op.position) + 56 content.slice(op.position + op.length!); 57 } 58 } 59}

CRDT (Conflict-free Replicated Data Types)#

1// Simple LWW (Last-Writer-Wins) Map CRDT 2interface LWWEntry<T> { 3 value: T; 4 timestamp: number; 5 clientId: string; 6} 7 8class LWWMap<T> { 9 private entries: Map<string, LWWEntry<T>> = new Map(); 10 11 set(key: string, value: T, clientId: string) { 12 const existing = this.entries.get(key); 13 const timestamp = Date.now(); 14 15 if (!existing || timestamp > existing.timestamp || 16 (timestamp === existing.timestamp && clientId > existing.clientId)) { 17 this.entries.set(key, { value, timestamp, clientId }); 18 } 19 } 20 21 get(key: string): T | undefined { 22 return this.entries.get(key)?.value; 23 } 24 25 merge(other: LWWMap<T>) { 26 for (const [key, entry] of other.entries) { 27 const existing = this.entries.get(key); 28 29 if (!existing || entry.timestamp > existing.timestamp || 30 (entry.timestamp === existing.timestamp && entry.clientId > existing.clientId)) { 31 this.entries.set(key, entry); 32 } 33 } 34 } 35 36 toJSON() { 37 return Object.fromEntries(this.entries); 38 } 39}

Using Yjs for Complex Documents#

1import * as Y from 'yjs'; 2import { WebsocketProvider } from 'y-websocket'; 3 4// Initialize shared document 5const ydoc = new Y.Doc(); 6const ytext = ydoc.getText('content'); 7const provider = new WebsocketProvider('wss://server.com', 'room-id', ydoc); 8 9// Local edits 10ytext.insert(0, 'Hello '); 11ytext.insert(6, 'World'); 12 13// Listen for changes 14ytext.observe((event) => { 15 console.log('Document changed:', ytext.toString()); 16}); 17 18// Awareness (presence) 19const awareness = provider.awareness; 20 21awareness.setLocalState({ 22 user: { 23 name: 'Alice', 24 color: '#ff0000', 25 }, 26 cursor: { x: 100, y: 200 }, 27}); 28 29awareness.on('change', () => { 30 const states = awareness.getStates(); 31 // Update cursor UI 32});

Optimistic Updates#

1class OptimisticStore<T> { 2 private confirmedState: T; 3 private pendingChanges: Array<{ 4 id: string; 5 apply: (state: T) => T; 6 revert: (state: T) => T; 7 }> = []; 8 9 constructor(initialState: T) { 10 this.confirmedState = initialState; 11 } 12 13 getState(): T { 14 return this.pendingChanges.reduce( 15 (state, change) => change.apply(state), 16 this.confirmedState 17 ); 18 } 19 20 applyOptimistic( 21 id: string, 22 apply: (state: T) => T, 23 revert: (state: T) => T 24 ) { 25 this.pendingChanges.push({ id, apply, revert }); 26 return this.getState(); 27 } 28 29 confirm(id: string, newConfirmedState: T) { 30 this.confirmedState = newConfirmedState; 31 this.pendingChanges = this.pendingChanges.filter(c => c.id !== id); 32 } 33 34 reject(id: string) { 35 this.pendingChanges = this.pendingChanges.filter(c => c.id !== id); 36 } 37}

Best Practices#

  1. Handle reconnection: Sync state after connection drops
  2. Implement throttling: Don't send every keystroke
  3. Show connection status: Users should know when they're offline
  4. Graceful degradation: App should work without collaboration
  5. Conflict visualization: Show when conflicts occur

Conclusion#

Real-time collaboration requires careful handling of concurrent edits. Start with simple presence features and WebSockets, then add conflict resolution with CRDTs or OT as needed.

Share this article

Help spread the word about Bootspring