Building Real-Time Applications with WebSockets and Server-Sent Events
Learn how to build scalable real-time applications using WebSockets and SSE, covering implementation patterns, scaling with Redis, and production deployment.
Users expect real-time experiences: live notifications, collaborative editing, streaming dashboards, and instant messaging. Building these features requires moving beyond the request-response model that HTTP was designed for. WebSockets and Server-Sent Events (SSE) are the two primary technologies for real-time communication on the web, and choosing the right one, then implementing it reliably, is the difference between a smooth user experience and a fragile system that breaks under load. This guide covers the decision framework, implementation patterns, scaling strategies, and production deployment considerations for real-time applications.
WebSocket vs SSE vs Polling: Making the Right Choice
Before writing any code, understand what each technology provides and where it fits.
HTTP Polling is the simplest approach. The client sends requests at regular intervals (for example, every five seconds) to check for updates. It is easy to implement and works everywhere, but it wastes bandwidth when there are no updates and introduces latency equal to half the polling interval on average. Use it only when updates are infrequent and latency tolerance is high, such as checking for new email every 30 seconds.
Long Polling improves on polling by holding the request open until the server has new data to send. The client immediately reconnects after receiving a response. This reduces unnecessary requests but still has overhead from repeatedly establishing HTTP connections.
Server-Sent Events (SSE) provide a one-way channel from server to client over a standard HTTP connection. The server pushes events and the client receives them through the EventSource API. SSE supports automatic reconnection, event IDs for resume-after-disconnect, and event types for multiplexing different message categories on a single connection.
// Client-side SSE
const events = new EventSource('/api/events');
events.addEventListener('notification', (e) => {
const data = JSON.parse(e.data);
showNotification(data);
});
events.addEventListener('price-update', (e) => {
const data = JSON.parse(e.data);
updatePriceDisplay(data);
});
events.onerror = () => {
console.log('Connection lost, reconnecting...');
// EventSource automatically reconnects
};WebSockets provide a full-duplex, bidirectional channel over a single TCP connection. Both client and server can send messages at any time. WebSockets are the right choice when the client needs to send frequent messages to the server (chat, collaborative editing, gaming) or when you need the lowest possible latency.
A decision framework:
| Requirement | Best Technology |
|---|---|
| Server-to-client updates only | SSE |
| Bidirectional communication | WebSocket |
| Low message frequency, high tolerance for latency | Long Polling |
| Browser compatibility is critical (including proxies) | SSE (falls back gracefully) |
| Binary data transfer | WebSocket |
| Simplest implementation | SSE |
The most common mistake is defaulting to WebSockets when SSE would suffice. SSE is simpler to implement, works through HTTP proxies and load balancers without special configuration, and handles reconnection automatically. Use WebSockets only when you genuinely need bidirectional communication.
WebSocket Implementation Patterns
When WebSockets are the right choice, a clean implementation follows established patterns.
Server-side with Node.js, the ws library provides a lightweight, performant WebSocket server:
import { WebSocketServer } from 'ws';
import { createServer } from 'http';
const server = createServer();
const wss = new WebSocketServer({ server });
// Connection management
const clients = new Map();
wss.on('connection', (ws, req) => {
const userId = authenticateConnection(req);
if (!userId) {
ws.close(4001, 'Unauthorized');
return;
}
clients.set(userId, ws);
console.log(`Client connected: ${userId}`);
ws.on('message', (data) => {
try {
const message = JSON.parse(data);
handleMessage(userId, message);
} catch (err) {
ws.send(JSON.stringify({ error: 'Invalid message format' }));
}
});
ws.on('close', () => {
clients.delete(userId);
console.log(`Client disconnected: ${userId}`);
});
// Heartbeat to detect dead connections
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
});
// Ping all clients every 30 seconds, terminate unresponsive ones
const heartbeat = setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
server.listen(8080);Message protocol design is critical for maintainability. Define a structured message format rather than sending ad-hoc strings:
interface WebSocketMessage {
type: string; // 'chat.message', 'cursor.move', 'presence.update'
payload: unknown; // Message-specific data
id: string; // Unique message ID for deduplication
timestamp: number; // Server timestamp
}Use a type hierarchy with dot notation (chat.message, chat.typing, chat.read) that maps cleanly to handler functions. This makes it easy to route messages to the correct handler and add new message types without modifying the routing logic.
Client-side implementation should include reconnection logic, message queuing, and connection state management:
class WebSocketClient {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private messageQueue: string[] = [];
private handlers = new Map<string, Function[]>();
connect(url: string, token: string) {
this.ws = new WebSocket(`${url}?token=${token}`);
this.ws.onopen = () => {
this.reconnectAttempts = 0;
this.flushQueue();
};
this.ws.onmessage = (event) => {
const message = JSON.parse(event.data);
const typeHandlers = this.handlers.get(message.type) || [];
typeHandlers.forEach(handler => handler(message.payload));
};
this.ws.onclose = (event) => {
if (event.code !== 1000) this.reconnect(url, token);
};
}
send(type: string, payload: unknown) {
const message = JSON.stringify({ type, payload, id: crypto.randomUUID() });
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(message);
} else {
this.messageQueue.push(message);
}
}
private reconnect(url: string, token: string) {
if (this.reconnectAttempts >= this.maxReconnectAttempts) return;
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
this.reconnectAttempts++;
setTimeout(() => this.connect(url, token), delay);
}
private flushQueue() {
while (this.messageQueue.length > 0) {
this.ws?.send(this.messageQueue.shift()!);
}
}
}Scaling with Redis Pub/Sub
A single WebSocket server can handle thousands of connections, but real applications run multiple server instances behind a load balancer. When user A is connected to server 1 and user B is connected to server 2, a message from A to B must cross server boundaries. Redis Pub/Sub solves this.
Each server subscribes to relevant Redis channels. When a message arrives that needs to reach a client on a different server, it is published to Redis, which broadcasts it to all subscribed servers. Only the server holding the target client's connection delivers the message.
import Redis from 'ioredis';
const publisher = new Redis();
const subscriber = new Redis();
// Subscribe to channels for this server's connected users
subscriber.subscribe('broadcasts', 'room:general');
subscriber.on('message', (channel, message) => {
const parsed = JSON.parse(message);
if (channel === 'broadcasts') {
// Send to all connected clients on this server
broadcastToAll(parsed);
} else if (channel.startsWith('room:')) {
// Send to clients in the specific room on this server
broadcastToRoom(channel, parsed);
}
});
// When a client sends a message, publish to Redis
function handleClientMessage(userId, message) {
if (message.type === 'chat.message') {
const outgoing = {
type: 'chat.message',
payload: { from: userId, text: message.payload.text },
timestamp: Date.now(),
};
publisher.publish(`room:${message.payload.room}`, JSON.stringify(outgoing));
}
}For applications with many rooms or channels, Redis Pub/Sub scales well but has a limitation: every message on a channel is delivered to every subscriber, even if that server has no clients interested in the channel. For very high channel counts, consider Redis Streams or a dedicated message broker like NATS, which supports more efficient message filtering.
Connection Management and Authentication
Managing WebSocket connections at scale requires attention to resource limits, authentication, and graceful handling of disconnections.
Authentication should happen during the connection handshake, not after. Pass the authentication token as a query parameter or in the initial HTTP upgrade request headers. Validate the token before accepting the WebSocket connection. Never rely on post-connection authentication messages, as an unauthenticated connection consumes server resources.
Connection limits prevent a single client from consuming excessive resources. Set per-IP and per-user connection limits at the load balancer or application level. A reasonable default is five concurrent connections per user (accounting for multiple tabs or devices).
Graceful shutdown matters during deployments. When a server instance is being replaced, it should stop accepting new connections, send a close frame to all connected clients with a code indicating the client should reconnect, wait for in-flight messages to complete, and then terminate. Clients should handle this close code by reconnecting immediately (without backoff) since the disconnection is expected.
Connection state tracking enables features like presence indicators and typing notifications. Maintain an in-memory map of connected users per server and synchronize it via Redis for cross-server visibility.
// Track presence in Redis with automatic expiration
async function updatePresence(userId, status) {
await redis.hset('presence', userId, JSON.stringify({
status,
serverId: SERVER_ID,
lastSeen: Date.now(),
}));
await redis.expire('presence', 120); // Expire if not refreshed
}
// Refresh presence on heartbeat
heartbeatInterval = setInterval(() => {
connectedUsers.forEach(userId => updatePresence(userId, 'online'));
}, 30000);Reconnection Strategies
Network interruptions are inevitable. A robust reconnection strategy ensures the user experience recovers seamlessly.
Exponential backoff with jitter prevents thundering herd problems when many clients disconnect simultaneously (for example, during a server restart). The jitter component ensures that clients do not all reconnect at the exact same moment.
function getReconnectDelay(attempt) {
const baseDelay = 1000;
const maxDelay = 30000;
const exponentialDelay = baseDelay * Math.pow(2, attempt);
const jitter = Math.random() * 1000;
return Math.min(exponentialDelay + jitter, maxDelay);
}Message reconciliation handles messages missed during disconnection. Track the last received message ID on the client. When reconnecting, send this ID to the server, which replays any messages sent after that ID. This requires the server to buffer recent messages, typically in Redis with a TTL.
State synchronization for collaborative applications goes beyond message replay. After reconnection, the client should request the current state (the full document, the current game state, the latest dashboard data) rather than trying to reconstruct it from missed messages. Design your protocol with a sync message type that returns the authoritative current state.
Production Deployment Considerations
Deploying WebSocket applications in production requires infrastructure configuration that differs from standard HTTP services.
Load balancers must support WebSocket connections. Configure sticky sessions (session affinity) based on a connection ID or client IP so that upgrade requests and subsequent WebSocket frames route to the same backend server. On AWS, Application Load Balancers support WebSockets natively. On Nginx, add the proxy upgrade headers:
location /ws {
proxy_pass http://websocket_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}Connection timeouts must be configured at every layer: the load balancer, the reverse proxy, and the application. Set idle timeouts high enough to accommodate quiet connections (at least 60 seconds) and use application-level heartbeats (ping/pong every 30 seconds) to keep connections alive through intermediate proxies.
Monitoring should track active connection count per server, connection churn rate (connections opened/closed per minute), message throughput, message delivery latency, and reconnection rate. A spike in reconnection rate indicates network issues or server instability. A steady increase in active connections without corresponding traffic growth may indicate connection leaks.