Building Real-Time Features with WebSockets and SSE
Implement real-time features using WebSocket and Server-Sent Events: connection management, reconnection, scaling with Redis pub/sub, and choosing the right protocol.
What you'll learn
- ✓Implementing a WebSocket server with connection lifecycle management
- ✓Building an SSE endpoint with automatic reconnection
- ✓Scaling real-time connections across multiple server instances
- ✓Heartbeat and reconnection patterns for production reliability
- ✓Choosing between WebSocket and SSE for specific feature requirements
Prerequisites
None — this post is self-contained.
Comparing WebSocket and SSE at a theoretical level is straightforward. Building production-ready real-time features with them is where the complexity lives. Connections drop, servers restart, load balancers time out, and horizontal scaling breaks naive implementations. This guide covers the implementation patterns that make real-time features reliable in production.
WebSocket Server Implementation
A WebSocket server must handle connection lifecycle: open, message, error, and close events. Here is a production-quality Node.js implementation using the ws library:
import { WebSocketServer } from 'ws';
import { createServer } from 'http';
const server = createServer();
const wss = new WebSocketServer({ server });
// Track connected clients by user ID
const clients = new Map();
wss.on('connection', (ws, req) => {
const userId = authenticateRequest(req);
if (!userId) {
ws.close(4001, 'Unauthorized');
return;
}
clients.set(userId, ws);
ws.isAlive = true;
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('pong', () => {
ws.isAlive = true;
});
ws.on('close', () => {
clients.delete(userId);
});
ws.on('error', (err) => {
console.error(`WebSocket error for ${userId}:`, err.message);
clients.delete(userId);
});
});
// Heartbeat: detect dead connections
const heartbeat = setInterval(() => {
wss.clients.forEach((ws) => {
if (!ws.isAlive) {
ws.terminate();
return;
}
ws.isAlive = false;
ws.ping();
});
}, 30000);
wss.on('close', () => clearInterval(heartbeat));
server.listen(8080);
The heartbeat interval pings every client every 30 seconds. If a client does not respond with a pong before the next ping, the connection is considered dead and terminated. Without heartbeats, half-open connections accumulate silently.
WebSocket Client with Reconnection
The browser WebSocket API does not reconnect automatically. Implement exponential backoff:
class ReconnectingWebSocket {
constructor(url, options = {}) {
this.url = url;
this.maxRetries = options.maxRetries ?? 10;
this.baseDelay = options.baseDelay ?? 1000;
this.maxDelay = options.maxDelay ?? 30000;
this.retries = 0;
this.listeners = new Map();
this.connect();
}
connect() {
this.ws = new WebSocket(this.url);
this.ws.onopen = () => {
this.retries = 0;
this.emit('open');
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.emit('message', data);
} catch {
this.emit('message', event.data);
}
};
this.ws.onclose = (event) => {
this.emit('close', event);
if (event.code !== 4001 && this.retries < this.maxRetries) {
this.scheduleReconnect();
}
};
this.ws.onerror = () => {
// onclose will fire after onerror
};
}
scheduleReconnect() {
const delay = Math.min(
this.baseDelay * Math.pow(2, this.retries) + Math.random() * 1000,
this.maxDelay
);
this.retries++;
setTimeout(() => this.connect(), delay);
}
send(data) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
on(event, callback) {
if (!this.listeners.has(event)) this.listeners.set(event, []);
this.listeners.get(event).push(callback);
}
emit(event, data) {
for (const cb of this.listeners.get(event) ?? []) cb(data);
}
}
The jitter (Math.random() * 1000) prevents a thundering herd when all clients reconnect simultaneously after a server restart.
SSE Server Implementation
Server-Sent Events are simpler because they are unidirectional. The server writes text events to an HTTP response that stays open:
import express from 'express';
const app = express();
const sseClients = new Map();
app.get('/events', (req, res) => {
const userId = authenticateRequest(req);
if (!userId) return res.status(401).end();
// Set SSE headers
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // Disable Nginx buffering
});
// Send initial connection event
res.write(`event: connected\ndata: ${JSON.stringify({ userId })}\n\n`);
// Track client
sseClients.set(userId, res);
// Send heartbeat to prevent proxy timeouts
const heartbeat = setInterval(() => {
res.write(': heartbeat\n\n');
}, 15000);
req.on('close', () => {
clearInterval(heartbeat);
sseClients.delete(userId);
});
});
// Broadcast to specific user
function sendEvent(userId, event, data) {
const client = sseClients.get(userId);
if (client) {
client.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
}
}
// Broadcast to all clients
function broadcast(event, data) {
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
for (const client of sseClients.values()) {
client.write(payload);
}
}
The X-Accel-Buffering: no header is critical when behind Nginx. Without it, Nginx buffers the response and clients receive events in large batches instead of in real time.
The heartbeat comment line (: heartbeat) keeps the connection alive through load balancers and proxies that close idle connections.
SSE Client with EventSource
The browser’s EventSource API handles reconnection automatically with a default 3-second delay:
const source = new EventSource('/events', {
withCredentials: true, // Send cookies for authentication
});
source.addEventListener('notification', (event) => {
const data = JSON.parse(event.data);
showNotification(data);
});
source.addEventListener('update', (event) => {
const data = JSON.parse(event.data);
updateDashboard(data);
});
source.onerror = () => {
// EventSource reconnects automatically
// Update UI to show "reconnecting..." state
if (source.readyState === EventSource.CONNECTING) {
showReconnectingBanner();
}
};
The server can control the reconnection delay by sending a retry field:
// Server-side: set client retry to 5 seconds
res.write('retry: 5000\n\n');
Scaling with Redis Pub/Sub
When your application runs on multiple server instances behind a load balancer, a client connected to server A will not receive events published by server B. Redis pub/sub bridges this gap:
import Redis from 'ioredis';
const publisher = new Redis();
const subscriber = new Redis();
// Subscribe to events
subscriber.subscribe('events');
subscriber.on('message', (channel, message) => {
const { userId, event, data } = JSON.parse(message);
if (userId) {
sendEvent(userId, event, data);
} else {
broadcast(event, data);
}
});
// Publish from any server instance
async function publishEvent(userId, event, data) {
await publisher.publish('events', JSON.stringify({ userId, event, data }));
}
Every server instance subscribes to the Redis channel. When any instance calls publishEvent, all instances receive the message and forward it to their locally connected clients.
When to Use Which
Use SSE when your feature is server-to-client only: live dashboards, notification feeds, real-time logs, stock tickers, and build status updates. SSE is simpler to implement, works over standard HTTP, reconnects automatically, and passes through all proxies without special configuration.
Use WebSockets when you need bidirectional communication: chat applications, collaborative editing, multiplayer games, and interactive terminals. WebSockets have lower per-message overhead for high-frequency bidirectional messaging.
For most applications, SSE covers the real-time requirements. Add WebSockets only for features that genuinely require the client to send frequent messages to the server.
Production Checklist
Before deploying real-time features, verify these items:
- Authentication: Validate credentials on connection, not just on the initial HTTP request. For WebSockets, authenticate during the upgrade handshake. For SSE, use cookies or token parameters.
- Rate limiting: Limit incoming WebSocket messages per connection to prevent abuse.
- Connection limits: Set maximum connections per user to prevent resource exhaustion.
- Proxy configuration: Increase proxy timeouts for long-lived connections. Nginx default is 60 seconds, which drops SSE connections prematurely.
- Monitoring: Track active connections, message throughput, and reconnection rates. Alert on sudden drops in connection count.
- Graceful shutdown: Drain connections during deployment by stopping new connections first, then closing existing ones with a brief delay.
Key Takeaways
Building reliable real-time features requires handling the unglamorous parts: reconnection with backoff and jitter, heartbeats to detect dead connections, proxy configuration to prevent buffering, and Redis pub/sub for horizontal scaling. SSE covers most server-to-client use cases with less complexity than WebSockets. Use WebSockets when you need bidirectional messaging. In both cases, invest in connection lifecycle management and monitoring before scaling to thousands of concurrent connections.
Related articles
- Web WebSockets vs Server-Sent Events
How WebSockets and SSE differ in transport, direction, reconnection, and operations, with guidance on when each one is the right pick for real-time web apps.
- Django Django Channels: Real-Time WebSockets
Add real-time WebSocket support to Django with Channels — consumers, routing, channel layers, groups, and building a live chat.
- FastAPI WebSocket Endpoints in FastAPI for Real-Time Features
Build real-time features with FastAPI WebSockets including chat rooms, live notifications, and connection management patterns.
- GraphQL GraphQL Subscriptions for Real-Time Data
Build real-time features with GraphQL subscriptions using WebSockets, including setup with Apollo Server and client-side integration.