DNAπŸ—οΈ System DesignReal-Time Architecture
πŸ¦–DinosaurSystem DesignWebSocketsReal-Time

Real-Time Architecture

From polling to WebSockets to CRDTs β€” how to architect frontends that update in real time without melting the server or confusing the user.

Real-Time Architecture

Real-time isn't a feature β€” it's an architecture decision that changes everything: how you manage connections, how you handle state, how you deploy, and how you think about data consistency. Choosing the wrong real-time pattern costs you either performance or complexity. Choosing the right one makes your app feel alive.

The Real-Time Spectrum

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Technique       β”‚ Latency    β”‚ Direction    β”‚ Complexity        β”‚
│──────────────────┼────────────┼──────────────┼───────────────────│
β”‚  Short Polling   β”‚ High       β”‚ Client β†’ Srv β”‚ Low               β”‚
β”‚  Long Polling    β”‚ Medium     β”‚ Client β†’ Srv β”‚ Medium            β”‚
β”‚  SSE             β”‚ Low        β”‚ Srv β†’ Client β”‚ Low               β”‚
β”‚  WebSocket       β”‚ Very Low   β”‚ Bidirectionalβ”‚ High              β”‚
β”‚  WebTransport    β”‚ Very Low   β”‚ Bidirectionalβ”‚ Very High         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Short Polling

useEffect(() => {
  const interval = setInterval(async () => {
    const data = await fetch('/api/notifications');
    setNotifications(await data.json());
  }, 5000);
  return () => clearInterval(interval);
}, []);

Simple. Wasteful. Every 5 seconds you hit the server whether anything changed or not. For 10,000 users polling every 5s, that's 2,000 requests/second β€” most returning "no changes."

Long Polling

async function longPoll() {
  try {
    const response = await fetch('/api/notifications/poll', {
      signal: AbortSignal.timeout(30_000),
    });
    const data = await response.json();
    setNotifications(prev => [...data.new, ...prev]);
  } catch (error) {
    if (error.name !== 'AbortError') {
      await new Promise(resolve => setTimeout(resolve, 3000));
    }
  }
  longPoll();
}

The server holds the connection open until there's new data or a timeout. Better than polling for low-frequency updates, but each response requires a new HTTP connection.

Server-Sent Events (SSE)

function useSSE(url: string) {
  const [data, setData] = useState(null);
 
  useEffect(() => {
    const source = new EventSource(url);
 
    source.addEventListener('notification', (event) => {
      setData(JSON.parse(event.data));
    });
 
    source.addEventListener('heartbeat', () => {
      // Connection alive
    });
 
    source.onerror = () => {
      source.close();
      setTimeout(() => {
        // Reconnect logic (EventSource auto-reconnects by default)
      }, 3000);
    };
 
    return () => source.close();
  }, [url]);
 
  return data;
}

SSE strengths:

  • Built-in reconnection with Last-Event-ID
  • Works over HTTP/2 (multiplexed, no connection limit)
  • Text-based, simple server implementation
  • Automatic browser reconnection

SSE limitations:

  • Server β†’ Client only (no bidirectional communication)
  • Text only (no binary)
  • Limited to ~6 connections per domain on HTTP/1.1

When SSE Is Enough

Notifications      β†’ SSE βœ…  (server pushes, client reads)
Live scores        β†’ SSE βœ…  (server pushes updates)
Stock ticker       β†’ SSE βœ…  (server pushes prices)
Chat messages      β†’ SSE ❌  (need to send messages too)
Collaborative edit β†’ SSE ❌  (need bidirectional sync)
Gaming             β†’ SSE ❌  (need low-latency bidirectional)

WebSockets

Full-duplex, persistent TCP connection. Both client and server can send messages at any time.

class WebSocketManager {
  private ws: WebSocket | null = null;
  private reconnectAttempts = 0;
  private maxReconnectAttempts = 10;
  private listeners = new Map<string, Set<Function>>();
  private messageQueue: string[] = [];
 
  connect(url: string) {
    this.ws = new WebSocket(url);
 
    this.ws.onopen = () => {
      this.reconnectAttempts = 0;
      this.flushQueue();
      this.startHeartbeat();
    };
 
    this.ws.onmessage = (event) => {
      const message = JSON.parse(event.data);
      this.listeners.get(message.type)?.forEach(fn => fn(message.payload));
    };
 
    this.ws.onclose = (event) => {
      this.stopHeartbeat();
      if (!event.wasClean) this.reconnect(url);
    };
  }
 
  send(type: string, payload: unknown) {
    const message = JSON.stringify({ type, payload });
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(message);
    } else {
      this.messageQueue.push(message);
    }
  }
 
  on(type: string, handler: Function) {
    if (!this.listeners.has(type)) this.listeners.set(type, new Set());
    this.listeners.get(type)!.add(handler);
    return () => this.listeners.get(type)?.delete(handler);
  }
 
  private reconnect(url: string) {
    if (this.reconnectAttempts >= this.maxReconnectAttempts) return;
    const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 30_000);
    this.reconnectAttempts++;
    setTimeout(() => this.connect(url), delay);
  }
 
  private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
 
  private startHeartbeat() {
    this.heartbeatTimer = setInterval(() => {
      if (this.ws?.readyState === WebSocket.OPEN) {
        this.ws.send(JSON.stringify({ type: 'ping' }));
      }
    }, 25_000);
  }
 
  private stopHeartbeat() {
    if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
  }
 
  private flushQueue() {
    while (this.messageQueue.length > 0) {
      this.ws?.send(this.messageQueue.shift()!);
    }
  }
}

Connection Lifecycle

Client                              Server
  β”‚                                    β”‚
  │──── HTTP Upgrade Request ─────────▢│
  │◀─── 101 Switching Protocols ──────│
  β”‚                                    β”‚
  │◀════ WebSocket Connection ════════▢│
  β”‚                                    β”‚
  │──── { type: "subscribe",  ────────▢│
  β”‚       channel: "feed" }            β”‚
  β”‚                                    β”‚
  │◀──── { type: "post:new",  ────────│
  β”‚        payload: {...} }            β”‚
  β”‚                                    β”‚
  │──── ping ─────────────────────────▢│
  │◀──── pong ────────────────────────│
  β”‚                                    β”‚
  │──── close ────────────────────────▢│
  │◀──── close ───────────────────────│

Socket.io vs Native WebSocket

FactorNative WebSocketSocket.io
FallbackNoneLong polling β†’ WebSocket
ReconnectionManualBuilt-in with backoff
Rooms/namespacesManualBuilt-in
BinarySupportedSupported
BroadcastingManualBuilt-in
Bundle size0 KB (browser native)~45 KB
Protocol overheadMinimalHigher (custom protocol)

Recommendation: Use native WebSocket for new projects where you control both ends. Use Socket.io when you need fallbacks for legacy environments or want rapid prototyping with rooms.

WebTransport

The next generation β€” built on HTTP/3 (QUIC). Supports unreliable datagrams (no head-of-line blocking), multiple streams, and lower latency than WebSocket.

const transport = new WebTransport('https://example.com/wt');
await transport.ready;
 
const writer = transport.datagrams.writable.getWriter();
await writer.write(new TextEncoder().encode('fast unreliable message'));
 
const reader = transport.datagrams.readable.getReader();
const { value } = await reader.read();

Still emerging (2024+), but ideal for gaming, live video, and latency-critical applications.

Real-Time Data Patterns

Event Sourcing on the Frontend

Instead of storing current state, store a sequence of events and derive state:

type FeedEvent =
  | { type: 'POST_ADDED'; post: Post }
  | { type: 'POST_LIKED'; postId: string; userId: string }
  | { type: 'POST_DELETED'; postId: string }
  | { type: 'COMMENT_ADDED'; postId: string; comment: Comment };
 
function feedReducer(state: FeedState, event: FeedEvent): FeedState {
  switch (event.type) {
    case 'POST_ADDED':
      return { ...state, posts: [event.post, ...state.posts] };
    case 'POST_LIKED':
      return {
        ...state,
        posts: state.posts.map(p =>
          p.id === event.postId
            ? { ...p, likeCount: p.likeCount + 1 }
            : p
        ),
      };
    case 'POST_DELETED':
      return { ...state, posts: state.posts.filter(p => p.id !== event.postId) };
    default:
      return state;
  }
}

CRDTs for Collaboration

Conflict-free Replicated Data Types allow multiple users to edit simultaneously without coordination:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”                           β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  User A  β”‚                           β”‚  User B  β”‚
β”‚          β”‚                           β”‚          β”‚
β”‚ "Hello"  β”‚                           β”‚ "Hello"  β”‚
β”‚    β”‚     β”‚                           β”‚    β”‚     β”‚
β”‚ Insert   β”‚                           β”‚ Insert   β”‚
β”‚ " World" β”‚                           β”‚ "!"      β”‚
β”‚ at pos 5 β”‚                           β”‚ at pos 5 β”‚
β”‚    β”‚     β”‚          β”Œβ”€β”€β”€β”€β”€β”          β”‚    β”‚     β”‚
β”‚    └─────┼─────────▢│     β”‚β—€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”˜     β”‚
β”‚          β”‚          β”‚CRDT β”‚          β”‚          β”‚
β”‚          β”‚          β”‚Mergeβ”‚          β”‚          β”‚
β”‚    β”Œβ”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”‚     │──────────┼────┐     β”‚
β”‚    β–Ό     β”‚          β””β”€β”€β”€β”€β”€β”˜          β”‚    β–Ό     β”‚
β”‚"Hello    β”‚                           β”‚"Hello    β”‚
β”‚ World!"  β”‚                           β”‚ World!"  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                           β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Both users converge to the same state without conflict resolution logic. Libraries like Yjs and Automerge implement this:

import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
 
const doc = new Y.Doc();
const provider = new WebsocketProvider('wss://collab.example.com', 'doc-123', doc);
const text = doc.getText('content');
 
text.observe(event => {
  editor.setValue(text.toString());
});
 
function handleLocalEdit(delta: string, position: number) {
  text.insert(position, delta);
}

Operational Transform (OT)

The predecessor to CRDTs, used by Google Docs:

User A: Insert 'X' at position 3
User B: Delete character at position 1
 
Server receives A first, then transforms B:
  B's position shifts: delete at position 1 (no change needed since insert was after)
 
Server receives B first, then transforms A:
  A's position shifts: insert 'X' at position 2 (since char before was deleted)

OT requires a central server for transformation. CRDTs are decentralized but use more memory.

Presence Systems

Show who's online, who's typing, who's viewing the same document:

interface PresenceState {
  userId: string;
  status: 'online' | 'idle' | 'offline';
  cursor?: { x: number; y: number };
  lastActive: number;
}
 
function usePresence(channel: string) {
  const [peers, setPeers] = useState<Map<string, PresenceState>>(new Map());
 
  useEffect(() => {
    const ws = wsManager;
    ws.send('presence:join', { channel });
 
    const unsub1 = ws.on('presence:update', (state: PresenceState) => {
      setPeers(prev => new Map(prev).set(state.userId, state));
    });
 
    const unsub2 = ws.on('presence:leave', ({ userId }: { userId: string }) => {
      setPeers(prev => {
        const next = new Map(prev);
        next.delete(userId);
        return next;
      });
    });
 
    const idleTimer = setInterval(() => {
      ws.send('presence:heartbeat', { channel });
    }, 10_000);
 
    return () => { unsub1(); unsub2(); clearInterval(idleTimer); };
  }, [channel]);
 
  return peers;
}

Notification Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚              Notification System                     β”‚
β”‚                                                     β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚
β”‚  β”‚ Backend │───▢│ Notification │───▢│  WebSocket β”‚ β”‚
β”‚  β”‚ Events  β”‚    β”‚ Service      β”‚    β”‚  Gateway   β”‚ β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚
β”‚                        β”‚                  β”‚        β”‚
β”‚                        β–Ό                  β–Ό        β”‚
β”‚               β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚               β”‚ Push Service   β”‚  β”‚  Client    β”‚  β”‚
β”‚               β”‚ (FCM / APNs)  β”‚  β”‚  (browser) β”‚  β”‚
β”‚               β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚                                         β”‚         β”‚
β”‚                                    β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”  β”‚
β”‚                                    β”‚ Toast /   β”‚  β”‚
β”‚                                    β”‚ Badge /   β”‚  β”‚
β”‚                                    β”‚ Sound     β”‚  β”‚
β”‚                                    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Client-Side Notification Manager

type NotificationType = 'info' | 'success' | 'warning' | 'error';
 
interface Notification {
  id: string;
  type: NotificationType;
  title: string;
  message: string;
  timestamp: number;
  read: boolean;
  actionUrl?: string;
}
 
const useNotificationStore = create<NotificationStore>((set, get) => ({
  notifications: [],
  unreadCount: 0,
 
  addNotification: (notification: Omit<Notification, 'id' | 'timestamp' | 'read'>) => {
    const entry: Notification = {
      ...notification,
      id: crypto.randomUUID(),
      timestamp: Date.now(),
      read: false,
    };
    set(state => ({
      notifications: [entry, ...state.notifications].slice(0, 100),
      unreadCount: state.unreadCount + 1,
    }));
  },
 
  markAsRead: (id: string) => {
    set(state => ({
      notifications: state.notifications.map(n =>
        n.id === id ? { ...n, read: true } : n
      ),
      unreadCount: Math.max(0, state.unreadCount - 1),
    }));
  },
}));

Scaling WebSockets

The Problem

WebSocket connections are stateful. A user connected to Server A can't receive messages published from Server B.

Solution: Redis Pub/Sub

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ WS Srv 1 β”‚    β”‚ WS Srv 2 β”‚    β”‚ WS Srv 3 β”‚
β”‚ (1000    β”‚    β”‚ (1000    β”‚    β”‚ (1000    β”‚
β”‚  conns)  β”‚    β”‚  conns)  β”‚    β”‚  conns)  β”‚
β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
     β”‚               β”‚               β”‚
     β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                     β”‚
              β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”
              β”‚    Redis     β”‚
              β”‚   Pub/Sub    β”‚
              β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

When Server 1 needs to broadcast to a user on Server 2, it publishes to Redis. Server 2 subscribes and forwards to the connected client.

Sticky Sessions

Alternatively, route the same user to the same server using a load balancer:

Load Balancer (hash userId β†’ server)
  β”‚
  β”œβ”€β”€ userId: abc β†’ Server 1
  β”œβ”€β”€ userId: def β†’ Server 2
  └── userId: ghi β†’ Server 3

Simple but fragile β€” if Server 1 dies, all its users must reconnect and may lose messages.

Optimistic Updates in Real-Time

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Optimistic Update Flow (Chat Message)            β”‚
β”‚                                                   β”‚
β”‚  1. User types message, hits send                β”‚
β”‚  2. Message appears immediately (optimistic)      β”‚
β”‚     └─ Gray checkmark (sending)                  β”‚
β”‚  3. Send via WebSocket to server                 β”‚
β”‚  4. Server confirms receipt                       β”‚
β”‚     └─ Single checkmark (sent)                   β”‚
β”‚  5. Server confirms delivery to recipient        β”‚
β”‚     └─ Double checkmark (delivered)              β”‚
β”‚  6. Recipient reads                              β”‚
β”‚     └─ Blue double checkmark (read)              β”‚
β”‚                                                   β”‚
β”‚  On failure at step 3/4:                         β”‚
β”‚     └─ Red exclamation (failed) + retry button   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Offline-First with Sync

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚              Offline-First Architecture           β”‚
β”‚                                                  β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚  UI      │───▢│  Local   │───▢│  Sync    β”‚  β”‚
β”‚  β”‚  Layer   │◀───│  Store   │◀───│  Engine  β”‚  β”‚
β”‚  β”‚          β”‚    β”‚ (IndexedDB)β”‚   β”‚          β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜  β”‚
β”‚                                       β”‚        β”‚
β”‚                                  β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”  β”‚
β”‚                                  β”‚  Server  β”‚  β”‚
β”‚                                  β”‚  (when   β”‚  β”‚
β”‚                                  β”‚  online) β”‚  β”‚
β”‚                                  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Conflict Resolution Strategies

StrategyDescriptionBest For
Last-write-winsLatest timestamp winsSimple, low-conflict data
First-write-winsFirst change is preservedAppend-only data
MergeCombine both changesNon-overlapping fields
CRDTMathematically convergentCollaborative editing
ManualShow conflict to userCritical data (medical, financial)

Real-time architecture is a spectrum. Start with the simplest pattern that meets your latency requirements. Polling works for dashboards. SSE works for notifications. WebSockets work for chat. CRDTs work for collaboration. The architect's job is matching the pattern to the problem β€” not reaching for the most complex tool every time.