Real-Time WebSocket Architecture: Patterns That Actually Scale
REST is great until you need data in real-time. Trading platforms, live dashboards, and collaborative tools all need WebSocket connections that don't drop, don't lag, and don't crash your server.
Here's what I've learned building real-time features for the Nexural trading platform.
The Connection Lifecycle
Every WebSocket connection goes through 5 states:
CONNECTING → OPEN → SUBSCRIBED → RECEIVING → CLOSED
│ │
└──── RECONNECTING ◄─────────────────────┘
Most tutorials stop at OPEN. Production systems need all 5.
Pattern 1: Exponential Backoff Reconnection
Never reconnect immediately. Never reconnect with a fixed interval. Use exponential backoff with jitter:
class ReconnectingWebSocket {
private retryCount = 0;
private maxRetries = 10;
private baseDelay = 1000; // 1 second
private getDelay(): number {
const exponential = Math.min(
this.baseDelay * Math.pow(2, this.retryCount),
30000 // Cap at 30 seconds
);
// Add jitter: ±25% randomization
const jitter = exponential * (0.75 + Math.random() * 0.5);
return Math.floor(jitter);
}
reconnect() {
if (this.retryCount >= this.maxRetries) {
this.fallbackToPolling();
return;
}
const delay = this.getDelay();
console.log(\`Reconnecting in \${delay}ms (attempt \${this.retryCount + 1})\`);
setTimeout(() => this.connect(), delay);
this.retryCount++;
}
}
Why jitter matters: If your server goes down and 1,000 clients all reconnect at the exact same time with the same backoff schedule, you create a thundering herd that brings the server down again. Jitter spreads the reconnections.
Pattern 2: Heartbeat / Ping-Pong
WebSocket connections can silently die. The TCP connection stays open but no data flows. Heartbeats detect this:
// Client sends ping every 30 seconds
private startHeartbeat() {
this.heartbeatInterval = setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'ping', ts: Date.now() }));
// If no pong within 5 seconds, connection is dead
this.pongTimeout = setTimeout(() => {
this.ws.close();
this.reconnect();
}, 5000);
}
}, 30000);
}
// Server responds with pong
private handleMessage(data: any) {
if (data.type === 'pong') {
clearTimeout(this.pongTimeout);
return;
}
// Handle actual data...
}
Pattern 3: Subscription Management
Don't dump all data through one connection. Use topic-based subscriptions:
// Client subscribes to specific symbols
ws.send(JSON.stringify({
action: 'subscribe',
symbols: ['AAPL', 'TSLA', 'ES', 'NQ']
}));
// Server only sends data for subscribed symbols
// Client can unsubscribe without reconnecting
ws.send(JSON.stringify({
action: 'unsubscribe',
symbols: ['TSLA']
}));
This reduces bandwidth, simplifies client-side filtering, and lets the server optimize which data streams to maintain.
Pattern 4: Graceful Degradation
When WebSockets fail completely, fall back to HTTP polling. Don't show the user an error — show them slightly stale data:
class MarketDataProvider {
private mode: 'websocket' | 'polling' = 'websocket';
async getData(symbol: string) {
if (this.mode === 'websocket') {
return this.wsData[symbol]; // Real-time
}
// Polling fallback: fetch every 5 seconds
return fetch(\`/api/quotes/\${symbol}\`).then(r => r.json());
}
onWebSocketFail() {
this.mode = 'polling';
this.startPolling();
// Show subtle indicator: "Data delayed ~5s"
}
}
What I'd Do Differently
- Use a message queue (Redis Pub/Sub) between the data source and WebSocket server. Direct connections to market data APIs create tight coupling.
- Implement client-side message buffering. If the UI is busy rendering, buffer incoming messages and process them in the next animation frame.
- Add connection quality metrics. Track latency per connection and alert when it degrades before the user notices.
Real-time is hard because it fails in ways that are hard to reproduce. Build for failure from day one.