Front-end Engineering Lab
Patterns

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 done

Client 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 continuously

Server 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 history

Google Docs

Architecture:
├─ Operational Transformation
├─ WebSockets
├─ Server-side reconciliation
└─ Conflict resolution

Features:
- Real-time typing
- Comments
- Suggestions
- Version control

📚 Key Takeaways

  1. Choose the right protocol: WebSocket for bidirectional, SSE for server-to-client
  2. Handle disconnections: Implement reconnection with exponential backoff
  3. Queue messages: Don't lose data during disconnections
  4. Scale with Pub/Sub: Use Redis for multi-server setups
  5. Handle conflicts: Use OT or CRDT for collaborative editing
  6. Monitor presence: Track who's online and active
  7. Optimize bandwidth: Send only deltas, not full state
  8. Test edge cases: Slow networks, disconnections, race conditions

Real-time is complex but essential for modern apps. Start simple, measure, and scale as needed.

On this page