Realtime WebSocket UI Design
Frontend system design for WebSocket UIs — connection lifecycle, backoff, fan-in state, ordering, and fallbacks.
- 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
- Push into React Query cache (
setQueryData) for entities - Dedicated realtime store for presence/cursors
- 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
- WS vs SSE for read-heavy feeds
- Snapshot+delta vs event sourcing on client
- Single socket multiplex vs multiple
- 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.
Related on this site
- Design Realtime Collaboration Cursors
- Chat Message UI
- Design a Notification System UI
- System Design Interview Framework