Real-Time Architecture
Complete guide to building real-time features - WebSockets, Server-Sent Events, collaborative editing, and live updates
Modern apps need real-time updates for chat, notifications, collaborative editing, and live data. This guide covers patterns used by Slack, Figma, and Google Docs.
🎯 Real-Time Requirements
What is "Real-Time"?
Traditional (Request-Response):
Client: "Give me updates"
Server: "Here's current state"
Client waits...
Client: "Any updates?" (polling)
Server: "Nope"
→ Wasteful, slow
Real-Time (Push):
Client: "Keep me updated"
Server: "Will do"
Server: "Update!" (pushes when available)
Server: "Another update!"
→ Efficient, instant📡 Communication Protocols
1. WebSockets (Bidirectional)
Full-duplex communication channel.
How it Works:
1. Handshake (HTTP Upgrade):
Client → Server: "Upgrade: websocket"
Server → Client: "101 Switching Protocols"
2. Connection established (stays open)
3. Messages flow both ways:
Client ←→ Server
Client ←→ Server
Client ←→ Server
4. Close when doneClient Implementation:
class WebSocketClient {
private ws: WebSocket;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
constructor(url: string) {
this.connect(url);
}
private connect(url: string) {
this.ws = new WebSocket(url);
this.ws.onopen = () => {
console.log('Connected');
this.reconnectAttempts = 0;
};
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
this.handleMessage(message);
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
this.ws.onclose = () => {
console.log('Disconnected');
this.reconnect(url);
};
}
private reconnect(url: string) {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
console.log(`Reconnecting in ${delay}ms...`);
setTimeout(() => this.connect(url), delay);
}
}
send(message: any) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
} else {
console.warn('WebSocket not open');
}
}
private handleMessage(message: any) {
// Handle different message types
switch (message.type) {
case 'CHAT_MESSAGE':
this.onChatMessage(message.data);
break;
case 'USER_JOINED':
this.onUserJoined(message.data);
break;
// ... more handlers
}
}
}React Hook:
function useWebSocket(url: string) {
const [messages, setMessages] = useState<any[]>([]);
const [isConnected, setIsConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
const ws = new WebSocket(url);
wsRef.current = ws;
ws.onopen = () => setIsConnected(true);
ws.onclose = () => setIsConnected(false);
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
setMessages(prev => [...prev, message]);
};
return () => ws.close();
}, [url]);
const sendMessage = (message: any) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message));
}
};
return { messages, isConnected, sendMessage };
}When to Use:
- ✅ Chat applications
- ✅ Real-time games
- ✅ Live collaboration
- ✅ Live dashboards
- ✅ Need bidirectional communication
2. Server-Sent Events (SSE) (Unidirectional)
Server pushes updates to client over HTTP.
How it Works:
1. Client opens connection:
GET /events HTTP/1.1
Accept: text/event-stream
2. Server keeps connection open
3. Server sends events:
data: {"type": "update", "value": 42}\n\n
data: {"type": "update", "value": 43}\n\n
4. Client receives events continuouslyServer Implementation (Node.js):
// Express.js
app.get('/events', (req, res) => {
// Set headers for SSE
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
// Send initial message
res.write('data: {"type": "connected"}\n\n');
// Send updates every 5 seconds
const interval = setInterval(() => {
const data = {
type: 'update',
value: Math.random(),
timestamp: Date.now()
};
res.write(`data: ${JSON.stringify(data)}\n\n`);
}, 5000);
// Clean up on close
req.on('close', () => {
clearInterval(interval);
res.end();
});
});Client Implementation:
class SSEClient {
private eventSource: EventSource;
constructor(url: string) {
this.eventSource = new EventSource(url);
this.eventSource.onopen = () => {
console.log('SSE connected');
};
this.eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
this.handleUpdate(data);
};
this.eventSource.onerror = (error) => {
console.error('SSE error:', error);
// Automatically reconnects
};
}
close() {
this.eventSource.close();
}
private handleUpdate(data: any) {
// Handle updates
}
}React Hook:
function useSSE(url: string) {
const [data, setData] = useState<any>(null);
const [error, setError] = useState<any>(null);
useEffect(() => {
const eventSource = new EventSource(url);
eventSource.onmessage = (event) => {
setData(JSON.parse(event.data));
};
eventSource.onerror = (err) => {
setError(err);
};
return () => eventSource.close();
}, [url]);
return { data, error };
}When to Use:
- ✅ Live notifications
- ✅ Live scores/prices
- ✅ Activity streams
- ✅ Server logs streaming
- ❌ Need client-to-server messages (use WebSocket)
3. Long Polling (Fallback)
Client requests and server holds response until data available.
async function longPoll(url: string) {
while (true) {
try {
const response = await fetch(url, {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
// Process data
handleUpdate(data);
// Immediately start next poll
} catch (error) {
console.error('Poll error:', error);
// Wait before retrying
await new Promise(resolve => setTimeout(resolve, 5000));
}
}
}When to Use:
- ✅ Fallback when WebSocket not available
- ✅ Simple requirements
- ❌ High-frequency updates (inefficient)
💬 Chat Architecture
Basic Chat System
// Message types
interface Message {
id: string;
userId: string;
username: string;
text: string;
timestamp: number;
roomId: string;
}
// Client
class ChatClient {
private ws: WebSocket;
constructor(url: string) {
this.ws = new WebSocket(url);
this.setupHandlers();
}
private setupHandlers() {
this.ws.onmessage = (event) => {
const message: Message = JSON.parse(event.data);
switch (message.type) {
case 'MESSAGE':
this.onNewMessage(message);
break;
case 'USER_JOINED':
this.onUserJoined(message);
break;
case 'USER_LEFT':
this.onUserLeft(message);
break;
case 'TYPING':
this.onUserTyping(message);
break;
}
};
}
sendMessage(text: string, roomId: string) {
const message = {
type: 'MESSAGE',
text,
roomId,
timestamp: Date.now()
};
this.ws.send(JSON.stringify(message));
}
sendTyping(roomId: string) {
const message = {
type: 'TYPING',
roomId
};
this.ws.send(JSON.stringify(message));
}
}React Chat Component
function ChatRoom({ roomId }: { roomId: string }) {
const [messages, setMessages] = useState<Message[]>([]);
const [inputValue, setInputValue] = useState('');
const [typingUsers, setTypingUsers] = useState<string[]>([]);
const { sendMessage, isConnected } = useWebSocket();
// Typing indicator
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
// Debounce typing indicator
debouncedSendTyping(roomId);
};
const handleSend = () => {
if (inputValue.trim()) {
sendMessage({
type: 'MESSAGE',
text: inputValue,
roomId
});
setInputValue('');
}
};
return (
<div className="chat-room">
<div className="messages">
{messages.map(msg => (
<div key={msg.id} className="message">
<strong>{msg.username}:</strong> {msg.text}
</div>
))}
</div>
{typingUsers.length > 0 && (
<div className="typing-indicator">
{typingUsers.join(', ')} {typingUsers.length === 1 ? 'is' : 'are'} typing...
</div>
)}
<div className="input-area">
<input
value={inputValue}
onChange={handleInputChange}
onKeyPress={(e) => e.key === 'Enter' && handleSend()}
placeholder="Type a message..."
disabled={!isConnected}
/>
<button onClick={handleSend} disabled={!isConnected}>
Send
</button>
</div>
</div>
);
}👥 Presence System
Track who's online and what they're doing.
interface Presence {
userId: string;
username: string;
status: 'online' | 'away' | 'offline';
lastSeen: number;
currentPage?: string;
}
class PresenceManager {
private presences: Map<string, Presence> = new Map();
private heartbeatInterval: number = 30000; // 30s
constructor(private ws: WebSocket) {
this.startHeartbeat();
}
private startHeartbeat() {
setInterval(() => {
// Send heartbeat to server
this.ws.send(JSON.stringify({
type: 'HEARTBEAT',
timestamp: Date.now()
}));
}, this.heartbeatInterval);
}
updatePresence(userId: string, data: Partial<Presence>) {
const current = this.presences.get(userId);
const updated = { ...current, ...data, lastSeen: Date.now() };
this.presences.set(userId, updated as Presence);
// Broadcast to others
this.ws.send(JSON.stringify({
type: 'PRESENCE_UPDATE',
userId,
data: updated
}));
}
setStatus(status: Presence['status']) {
this.updatePresence(this.currentUserId, { status });
}
setCurrentPage(page: string) {
this.updatePresence(this.currentUserId, { currentPage: page });
}
getOnlineUsers(): Presence[] {
const now = Date.now();
return Array.from(this.presences.values())
.filter(p => now - p.lastSeen < 60000); // Online in last minute
}
}React Hook:
function usePresence(roomId: string) {
const [onlineUsers, setOnlineUsers] = useState<Presence[]>([]);
const { ws } = useWebSocket();
useEffect(() => {
const presence = new PresenceManager(ws);
// Track page visibility
const handleVisibilityChange = () => {
presence.setStatus(
document.hidden ? 'away' : 'online'
);
};
document.addEventListener('visibilitychange', handleVisibilityChange);
// Track page changes
presence.setCurrentPage(window.location.pathname);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
presence.setStatus('offline');
};
}, [ws, roomId]);
return { onlineUsers };
}✏️ Collaborative Editing
Operational Transformation (OT)
Transform operations to handle concurrent edits.
interface Operation {
type: 'insert' | 'delete';
position: number;
char?: string;
userId: string;
timestamp: number;
}
class OTEngine {
private doc: string = '';
private operations: Operation[] = [];
// Apply operation to document
apply(op: Operation): string {
switch (op.type) {
case 'insert':
this.doc =
this.doc.slice(0, op.position) +
op.char +
this.doc.slice(op.position);
break;
case 'delete':
this.doc =
this.doc.slice(0, op.position) +
this.doc.slice(op.position + 1);
break;
}
this.operations.push(op);
return this.doc;
}
// Transform operation against another
transform(op1: Operation, op2: Operation): Operation {
// If both insert at same position
if (op1.type === 'insert' && op2.type === 'insert') {
if (op1.position === op2.position) {
// Tie-break by user ID
if (op1.userId < op2.userId) {
return op1;
} else {
return { ...op1, position: op1.position + 1 };
}
}
// Adjust position if op2 is before op1
if (op2.position < op1.position) {
return { ...op1, position: op1.position + 1 };
}
}
// More transformation rules...
return op1;
}
}CRDT (Conflict-Free Replicated Data Type)
Simpler approach that automatically merges changes.
// Yjs example
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
function CollaborativeEditor() {
const [doc] = useState(() => new Y.Doc());
const [provider] = useState(() =>
new WebsocketProvider('ws://localhost:1234', 'my-room', doc)
);
useEffect(() => {
const yText = doc.getText('content');
// Listen to changes
yText.observe(event => {
console.log('Document changed:', yText.toString());
});
return () => provider.destroy();
}, [doc, provider]);
const handleChange = (value: string) => {
const yText = doc.getText('content');
yText.delete(0, yText.length);
yText.insert(0, value);
};
return <Editor onChange={handleChange} />;
}🔔 Live Notifications
Push Architecture
interface Notification {
id: string;
type: 'info' | 'success' | 'warning' | 'error';
title: string;
message: string;
timestamp: number;
read: boolean;
actionUrl?: string;
}
class NotificationManager {
private notifications: Notification[] = [];
private ws: WebSocket;
constructor(wsUrl: string) {
this.ws = new WebSocket(wsUrl);
this.setupListeners();
}
private setupListeners() {
this.ws.onmessage = (event) => {
const notification: Notification = JSON.parse(event.data);
this.addNotification(notification);
};
}
private addNotification(notification: Notification) {
this.notifications.unshift(notification);
// Show browser notification if permitted
if ('Notification' in window && Notification.permission === 'granted') {
new Notification(notification.title, {
body: notification.message,
icon: '/icon.png'
});
}
// Play sound
this.playNotificationSound();
// Emit event for UI update
window.dispatchEvent(new CustomEvent('notification', {
detail: notification
}));
}
markAsRead(id: string) {
const notification = this.notifications.find(n => n.id === id);
if (notification) {
notification.read = true;
// Send to server
this.ws.send(JSON.stringify({
type: 'MARK_READ',
notificationId: id
}));
}
}
}React Component:
function NotificationCenter() {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const handleNotification = (event: CustomEvent) => {
setNotifications(prev => [event.detail, ...prev]);
setUnreadCount(prev => prev + 1);
};
window.addEventListener('notification', handleNotification as any);
return () => window.removeEventListener('notification', handleNotification as any);
}, []);
const markAllAsRead = () => {
setNotifications(prev =>
prev.map(n => ({ ...n, read: true }))
);
setUnreadCount(0);
};
return (
<div className="notification-center">
<button onClick={() => setIsOpen(!isOpen)}>
🔔 {unreadCount > 0 && <span className="badge">{unreadCount}</span>}
</button>
{isOpen && (
<div className="notification-dropdown">
<div className="header">
<h3>Notifications</h3>
<button onClick={markAllAsRead}>Mark all as read</button>
</div>
<div className="list">
{notifications.length === 0 ? (
<div className="empty">No notifications</div>
) : (
notifications.map(notification => (
<div
key={notification.id}
className={`notification ${notification.read ? 'read' : 'unread'}`}
>
<strong>{notification.title}</strong>
<p>{notification.message}</p>
<time>{new Date(notification.timestamp).toLocaleString()}</time>
</div>
))
)}
</div>
</div>
)}
</div>
);
}🔄 Connection Resilience
Reconnection Strategy
class ResilientWebSocket {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private messageQueue: any[] = [];
constructor(private url: string) {
this.connect();
}
private connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
console.log('Connected');
this.reconnectAttempts = 0;
this.flushQueue();
};
this.ws.onclose = () => {
console.log('Disconnected');
this.reconnect();
};
this.ws.onerror = (error) => {
console.error('Error:', error);
};
}
private reconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnection attempts reached');
return;
}
this.reconnectAttempts++;
const delay = Math.min(
1000 * Math.pow(2, this.reconnectAttempts),
30000
);
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
setTimeout(() => this.connect(), delay);
}
send(message: any) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
} else {
// Queue message for when connection is restored
this.messageQueue.push(message);
}
}
private flushQueue() {
while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift();
this.send(message);
}
}
}📊 Scaling Real-Time Systems
Horizontal Scaling with Redis Pub/Sub
// Server 1, Server 2, Server 3 all connected to Redis
// When message received on any server:
io.on('connection', (socket) => {
socket.on('message', (data) => {
// Publish to Redis
redis.publish('chat:room:' + data.roomId, JSON.stringify(data));
});
});
// All servers subscribe to Redis
redis.subscribe('chat:room:*');
redis.on('message', (channel, message) => {
const data = JSON.parse(message);
const roomId = channel.split(':')[2];
// Broadcast to all clients in this room on THIS server
io.to(roomId).emit('message', data);
});Flow:
User A (Server 1) → Message
↓
Server 1 → Redis Pub
↓
Redis → All Servers
↓
Server 1, 2, 3 → Broadcast to connected clients🎯 Real-World Patterns
Slack (Chat)
Architecture:
├─ WebSocket connections
├─ Message queuing (Kafka)
├─ Presence system
├─ Typing indicators
└─ Redis for scaling
Features:
- Read receipts
- Mentions
- Threads
- Reactions (real-time)Figma (Collaboration)
Architecture:
├─ WebSockets for cursors
├─ CRDT for document sync
├─ Operational transformation
└─ Presence indicators
Features:
- Live cursors
- Simultaneous editing
- Conflict resolution
- Version historyGoogle Docs
Architecture:
├─ Operational Transformation
├─ WebSockets
├─ Server-side reconciliation
└─ Conflict resolution
Features:
- Real-time typing
- Comments
- Suggestions
- Version control📚 Key Takeaways
- Choose the right protocol: WebSocket for bidirectional, SSE for server-to-client
- Handle disconnections: Implement reconnection with exponential backoff
- Queue messages: Don't lose data during disconnections
- Scale with Pub/Sub: Use Redis for multi-server setups
- Handle conflicts: Use OT or CRDT for collaborative editing
- Monitor presence: Track who's online and active
- Optimize bandwidth: Send only deltas, not full state
- Test edge cases: Slow networks, disconnections, race conditions
Real-time is complex but essential for modern apps. Start simple, measure, and scale as needed.