| name | real-time-collaboration-patterns |
| description | This skill should be used when implementing real-time collaborative features (multiplayer editing, live cursors, presence, chat) - covers WebSocket patterns, CRDTs, conflict resolution, operational transforms, sync strategies, and implementation with Supabase Realtime, Socket.io, or Partykit. |
Real-Time Collaboration Patterns
Overview
Build multiplayer experiences where multiple users interact simultaneously. This skill teaches conflict-free synchronization, presence awareness, and real-time data patterns.
Core principle: Optimistic updates + eventual consistency = smooth collaborative UX.
When to Use
Use when implementing:
- Collaborative editing (docs, whiteboards, code)
- Live presence (who's online, cursors)
- Real-time chat
- Multiplayer features
- Live dashboards/monitoring
- Collaborative tools
Technology Selection
| Use Case | Best Choice | Why |
|---|---|---|
| Chat messages | Supabase Realtime | Simple, integrated with Postgres |
| Presence (cursors, status) | PartyKit or Supabase | Low latency, efficient |
| Collaborative editing | Yjs + WebSockets | CRDT for conflict-free edits |
| Live dashboards | Server-Sent Events | One-way data flow |
| Multiplayer games | PartyKit or Socket.io | Low latency, custom logic |
WebSocket Basics
Client-Side (React)
import {useEffect, useState} from 'react'
function useWebSocket(url: string) {
const [messages, setMessages] = useState<string[]>([])
const [ws, setWs] = useState<WebSocket | null>(null)
useEffect(() => {
const socket = new WebSocket(url)
socket.onopen = () => console.log('Connected')
socket.onmessage = (event) => {
setMessages(prev => [...prev, event.data])
}
socket.onerror = (error) => console.error('WebSocket error:', error)
socket.onclose = () => console.log('Disconnected')
setWs(socket)
return () => socket.close()
}, [url])
const send = (message: string) => {
ws?.send(message)
}
return {messages, send, connected: ws?.readyState === WebSocket.OPEN}
}
Server-Side (Node.js)
import {WebSocketServer} from 'ws'
const wss = new WebSocketServer({port: 8080})
const clients = new Set<WebSocket>()
wss.on('connection', (ws) => {
clients.add(ws)
ws.on('message', (data) => {
// Broadcast to all clients
clients.forEach(client => {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(data)
}
})
})
ws.on('close', () => {
clients.delete(ws)
})
})
Supabase Realtime (Easiest)
Setup
import {createClient} from '@supabase/supabase-js'
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
Subscribe to Changes
// Listen to database changes
const channel = supabase
.channel('messages')
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'messages'
}, (payload) => {
console.log('New message:', payload.new)
setMessages(prev => [...prev, payload.new])
})
.subscribe()
// Clean up
return () => {
supabase.removeChannel(channel)
}
Presence (Who's Online)
const channel = supabase.channel('room1')
// Track presence
channel
.on('presence', {event: 'sync'}, () => {
const state = channel.presenceState()
setOnlineUsers(Object.values(state).flat())
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
user: currentUser.id,
online_at: new Date().toISOString()
})
}
})
Broadcast (Real-time Events)
// Send events
channel.send({
type: 'broadcast',
event: 'cursor_move',
payload: {x: 100, y: 200, user: userId}
})
// Receive events
channel
.on('broadcast', {event: 'cursor_move'}, (payload) => {
updateCursor(payload.user, payload.x, payload.y)
})
.subscribe()
Conflict Resolution Strategies
Last Write Wins (Simple)
Use for:
- Non-critical data
- Single field updates
- Chat messages
- Notifications
Pattern:
// Timestamp determines winner
const latest = updates.reduce((latest, update) =>
update.timestamp > latest.timestamp ? update : latest
)
Operational Transforms (Complex)
Use for:
- Text editing
- When order of operations matters
Libraries:
- ShareDB
- Yjs
CRDTs (Conflict-Free Replicated Data Types)
Use for:
- Collaborative editing
- Offline-first apps
- Complex data structures
Libraries:
- Yjs (most popular)
- Automerge
Example (Yjs):
import * as Y from 'yjs'
import {WebsocketProvider} from 'y-websocket'
const doc = new Y.Doc()
const provider = new WebsocketProvider('ws://localhost:1234', 'room1', doc)
const yText = doc.getText('shared-text')
// Listen to changes
yText.observe(() => {
setContent(yText.toString())
})
// Make changes
yText.insert(0, 'Hello ')
Common Patterns
Pattern 1: Live Cursors
interface Cursor {
userId: string
x: number
y: number
color: string
}
function LiveCursors() {
const [cursors, setCursors] = useState<Map<string, Cursor>>(new Map())
useEffect(() => {
const channel = supabase.channel('cursors')
channel
.on('broadcast', {event: 'cursor'}, ({payload}) => {
setCursors(prev => new Map(prev).set(payload.userId, payload))
})
.subscribe()
// Track own cursor
const handleMouseMove = (e: MouseEvent) => {
channel.send({
type: 'broadcast',
event: 'cursor',
payload: {
userId: currentUser.id,
x: e.clientX,
y: e.clientY,
color: currentUser.color
}
})
}
window.addEventListener('mousemove', handleMouseMove)
return () => window.removeEventListener('mousemove', handleMouseMove)
}, [])
return (
<>
{Array.from(cursors.values()).map(cursor => (
<Cursor key={cursor.userId} {...cursor} />
))}
</>
)
}
Pattern 2: Optimistic Updates
async function sendMessage(text: string) {
const tempId = crypto.randomUUID()
// Optimistic: Show immediately
setMessages(prev => [...prev, {
id: tempId,
text,
pending: true
}])
try {
// Actually send to server
const {data} = await supabase
.from('messages')
.insert({text})
.select()
.single()
// Replace temp with real
setMessages(prev =>
prev.map(m => m.id === tempId ? data : m)
)
} catch (error) {
// Rollback on error
setMessages(prev => prev.filter(m => m.id !== tempId))
showError('Failed to send message')
}
}
Pattern 3: Presence Indicators
function PresenceList() {
const [online, setOnline] = useState<User[]>([])
useEffect(() => {
const channel = supabase.channel('presence')
channel
.on('presence', {event: 'sync'}, () => {
const state = channel.presenceState()
setOnline(Object.values(state).flat())
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
userId: currentUser.id,
name: currentUser.name,
avatar: currentUser.avatar
})
}
})
return () => supabase.removeChannel(channel)
}, [])
return (
<div>
{online.map(user => (
<Avatar key={user.userId} user={user} online={true} />
))}
</div>
)
}
Performance Considerations
Throttle updates:
import {throttle} from 'lodash'
const sendCursorUpdate = throttle((x, y) => {
channel.send({type: 'cursor', x, y})
}, 50) // Update max once per 50ms
Batch operations:
// Instead of sending each keystroke
yText.insert(position, character)
// Batch multiple changes
const changes = ['H', 'e', 'l', 'l', 'o']
yText.insert(position, changes.join(''))
Clean up inactive connections:
// Remove users offline >30s
const TIMEOUT = 30000
setInterval(() => {
const now = Date.now()
presences.forEach((presence, userId) => {
if (now - presence.lastSeen > TIMEOUT) {
presences.delete(userId)
}
})
}, 10000)
Resources
- Supabase Realtime: supabase.com/docs/guides/realtime
- Yjs (CRDT): yjs.dev
- PartyKit: partykit.io
- Socket.io: socket.io
Real-time collaboration makes apps feel magical. Start simple (Supabase), add complexity only when needed (Yjs/CRDTs).