Building Real-Time Web Apps With WebSockets
A practical guide to WebSocket architecture for production applications — connection management, reconnection strategies, and scaling considerations.
WebSockets unlock a class of user experiences that polling can’t match — live dashboards, collaborative editing, real-time notifications, chat. We’ve built WebSocket-powered features in chat platforms, venue management systems, and scheduling tools. Here’s what the production experience teaches you.
The Connection Lifecycle
A WebSocket connection isn’t a request — it’s a persistent session. You need to think about it as infrastructure, not a transaction.
When you open a WebSocket connection:
- HTTP handshake upgrades to WS protocol
- A persistent TCP connection is maintained
- Either party can send frames at any time
- Either party can close the connection
The naive assumption is that connections stay open indefinitely. In practice, mobile devices sleep, browsers throttle background tabs, proxies impose timeouts, and networks blip. Plan for disconnections from day one.
Client-Side Reconnection
class ReconnectingWebSocket {
constructor(url) {
this.url = url;
this.reconnectDelay = 1000;
this.maxDelay = 30000;
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.reconnectDelay = 1000; // reset backoff on success
this.onConnect?.();
};
this.ws.onclose = () => this.scheduleReconnect();
this.ws.onerror = () => this.ws.close();
this.ws.onmessage = (e) => this.onMessage?.(JSON.parse(e.data));
}
scheduleReconnect() {
setTimeout(() => this.connect(), this.reconnectDelay);
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxDelay);
}
}
Exponential backoff prevents thundering herd reconnections when a server restarts. Every client reconnecting simultaneously is the last thing a recovering server needs.
Server-Side Connection Management
Every open connection consumes server memory. At scale, this matters. A Node.js process can handle thousands of concurrent WebSocket connections, but you need to track them and clean them up when they close.
The pattern we use: maintain a Map of connections keyed by user or session ID. When a connection closes (for any reason), remove it from the map. This enables server-initiated messages: “send an update to user 123.”
Message Protocol Design
Define a typed message protocol early. Ad-hoc string messages become unmaintainable quickly.
type WSMessage =
| { type: 'reservation:created'; payload: Reservation }
| { type: 'reservation:updated'; payload: Partial<Reservation> }
| { type: 'table:status_changed'; payload: { tableId: string; status: TableStatus } }
| { type: 'ping'; payload: null };
A discriminated union gives you exhaustive type checking in handlers. Add a message schema validation step before dispatching — malformed messages from buggy clients shouldn’t crash your handler.
Scaling Beyond One Server
WebSockets are sticky — a connection lives on one server process. When you scale horizontally, a message for user A (connected to server 1) might be triggered by an event on server 2. The standard solution: a pub/sub layer (Redis Pub/Sub, or a managed service) that all server processes subscribe to.
Server 2 publishes the event to Redis. All servers receive it. Server 1 finds the connection for user A and delivers it. Clean separation between event publication and delivery.
This architecture handles horizontal scaling cleanly and keeps your WebSocket servers stateless with respect to business logic.