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#
- Handle reconnection: Sync state after connection drops
- Implement throttling: Don't send every keystroke
- Show connection status: Users should know when they're offline
- Graceful degradation: App should work without collaboration
- 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.