ESC

Type to search the knowledge base.

Realtime WebSocket UI Design

Frontend system design for WebSocket UIs — connection lifecycle, backoff, fan-in state, ordering, and fallbacks.

advanced4 min read
  • system-design
  • interview
  • architecture
  • websocket
  • realtime

Scope the problem

In scope: client architecture for realtime UX over WebSockets (or SSE): connect, auth, reconnect, message handling, UI store integration, fallbacks.

Out of scope: multi-region broker topology details (high-level only).

When WebSockets vs alternatives

Tech Good for
WebSocket bidirectional, high frequency
SSE server→client streams; simpler auth/proxy
Long poll legacy fallback
HTTP poll low frequency metrics

Architecture

RealtimeClient (singleton)
├── connection state machine
├── auth / refresh
├── subscribe(topic) refcount
├── inbound router → stores
└── outbound queue while offline
         │
         ▼
   UI hooks: useTopic(topic)

Connection state machine

idle → connecting → open ⇄ reconnecting → closed
                 ↘ failed
type ConnState = "connecting" | "open" | "reconnecting" | "closed";

class RealtimeClient {
  private ws?: WebSocket;
  private attempt = 0;

  connect() {
    this.ws = new WebSocket(urlWithToken());
    this.ws.onopen = () => {
      this.attempt = 0;
      this.flushOutbox();
      this.resubscribeAll();
    };
    this.ws.onclose = () => this.scheduleReconnect();
    this.ws.onmessage = (e) => this.route(JSON.parse(e.data));
  }

  scheduleReconnect() {
    const backoff = Math.min(30_000, 1000 * 2 ** this.attempt++);
    const jitter = Math.random() * 400;
    setTimeout(() => this.connect(), backoff + jitter);
  }
}

Visibility: pause reconnect storms when tab hidden optional; always resume on focus.

Auth

  • Short-lived token in query/header (browser WS can’t set arbitrary headers easily — prefer protocols after connect or cookie same-site)
  • On 4001 auth close → refresh session → reconnect
  • Don’t log tokens

Subscriptions

Refcount topics so multiple components share one subscribe:

function useChatRoom(roomId: string) {
  useEffect(() => {
    const unsub = client.subscribe(`room:${roomId}`);
    return unsub;
  }, [roomId]);
}

Server may send snapshots on subscribe; client replaces or merges carefully.

Message handling

type Envelope = {
  id: string; // for dedupe
  type: string;
  ts: number;
  payload: unknown;
};
Concern Approach
Dedupe last N ids set
Ordering per-topic sequence numbers
Gaps request resync / REST catch-up
Bursts batch into rAF before React setState

Never setState per packet for high-frequency streams without batching.

UI integration patterns

  1. Push into React Query cache (setQueryData) for entities
  2. Dedicated realtime store for presence/cursors
  3. Event log append for chat

Optimistic local echo for sends; reconcile with server id.

Fallback & degradation

WS fails → SSE if available → poll every 5–15s

Show connection indicator; queue user messages with “sending…”.

Performance & mobile

  • Binary protocols if JSON dominates CPU
  • Heartbeats / ping to detect half-open
  • Backoff with jitter (avoid thundering herd after outage)
  • Cap concurrent sockets (one per app)

Security

  • WSS only
  • Origin checks server-side
  • Authorize topic subscriptions server-side always
  • Rate limit client sends

Tradeoffs

  1. WS vs SSE for read-heavy feeds
  2. Snapshot+delta vs event sourcing on client
  3. Single socket multiplex vs multiple
  4. At-most-once vs request ACKs (complexity)

Interview close

Singleton client + state machine + exponential backoff → topic refcount → dedupe/seq → batch UI updates → REST catch-up on gaps → poll fallback. Separate ephemeral presence from durable entities.

Topic design

user:{id}:notify
room:{id}:chat
doc:{id}:awareness

Authorize on subscribe. Prefer server-enforced membership over trusting client topic strings.

Outbox for client→server

type Outbound = { id: string; topic: string; payload: unknown; acked: boolean };

When socket is down, enqueue user-visible actions (chat send). On open, flush in order; show failed if server rejects.

React integration anti-pattern

// bad: new WebSocket per component mount
useEffect(() => { const ws = new WebSocket(url); ... }, []);

Always share a module singleton or context-owned client. Components only subscribe/unsubscribe topics.

Observability

Log connect success rate, median reconnect time, messages dropped by dedupe, and UI frame time during bursts. Realtime bugs often present as “UI jank” rather than hard errors.

Further reading