WebSockets Tutorial: Real-Time Guide
Welcome to TopperBlog! 👋
I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.
🎯 What I Write About:
• AI/ML Engineering & LLMs
• Web3 & Blockchain Development
• System Design & Architecture
• Interview Preparation (FAANG)
• Freelancing & Remote Work
• Modern Tech Stacks (Next.js, React, Rust, TypeScript)
• Performance Optimization & Best Practices
💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.
📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.
🌐 Let's connect and grow together in this amazing tech journey!
#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering
Why HTTP Polling and Long-Polling Fail in 2025
Traditional approaches to real-time communication rely on clients repeatedly requesting updates from servers. Short polling creates constant network overhead, with most requests returning empty responses. Long-polling holds connections open until data arrives, but each response requires establishing a new connection, creating latency spikes and connection churn.
These patterns break down under modern constraints. Cloud infrastructure bills by request count and data transfer. A polling-based system with 10,000 active users checking every second generates 36 million requests per hour—translating to thousands of dollars in unnecessary API gateway costs. Connection establishment overhead adds 50-200ms latency per poll cycle, making sub-second updates impossible.
Server-Sent Events (SSE) provide unidirectional streaming but cannot handle client-to-server communication without separate HTTP requests. This creates architectural complexity and prevents true bidirectional patterns essential for collaborative editing, real-time gaming, and interactive dashboards.
Modern privacy regulations compound these issues. GDPR and CCPA require immediate data deletion capabilities. Polling systems cache data across multiple request cycles, making compliance tracking difficult. WebSockets maintain single persistent connections with clear data flow, simplifying audit trails and deletion workflows.
Understanding WebSocket Protocol Architecture
WebSockets begin as HTTP requests with an Upgrade header, transitioning to a persistent TCP connection. This handshake occurs once per client session, eliminating repeated connection overhead. The protocol uses framing to send discrete messages over the persistent connection, with built-in support for text and binary data.
The connection lifecycle involves four states: CONNECTING (0), OPEN (1), CLOSING (2), and CLOSED (3). Applications must handle state transitions gracefully, implementing reconnection logic and message queuing for network interruptions.
Unlike HTTP, WebSockets lack built-in authentication refresh mechanisms. Tokens embedded during the initial handshake remain valid for the connection lifetime. This creates security challenges when connections persist for hours or days—expired tokens cannot be refreshed without reconnecting.
Message ordering is guaranteed within a single connection but not across multiple connections from the same client. Applications requiring strict ordering must implement sequence numbers and server-side ordering logic.
Building a Production-Grade WebSocket Server
A robust WebSocket server requires connection management, message routing, authentication, and graceful degradation. Here's a production-ready implementation using Node.js with TypeScript:
import { WebSocketServer, WebSocket } from 'ws';
import { IncomingMessage } from 'http';
import { verify } from 'jsonwebtoken';
interface AuthenticatedWebSocket extends WebSocket {
userId: string;
subscriptions: Set<string>;
lastActivity: number;
}
interface Message {
type: 'subscribe' | 'unsubscribe' | 'publish' | 'ping';
channel?: string;
data?: unknown;
timestamp: number;
}
class RealtimeServer {
private wss: WebSocketServer;
private channels: Map<string, Set<AuthenticatedWebSocket>>;
private heartbeatInterval: NodeJS.Timeout;
private readonly HEARTBEAT_INTERVAL = 30000;
private readonly CONNECTION_TIMEOUT = 60000;
constructor(port: number) {
this.channels = new Map();
this.wss = new WebSocketServer({
port,
perMessageDeflate: {
zlibDeflateOptions: {
chunkSize: 1024,
memLevel: 7,
level: 3
},
zlibInflateOptions: {
chunkSize: 10 * 1024
},
threshold: 1024
}
});
this.setupConnectionHandler();
this.startHeartbeat();
}
private setupConnectionHandler(): void {
this.wss.on('connection', async (ws: WebSocket, req: IncomingMessage) => {
const token = this.extractToken(req);
if (!token) {
ws.close(1008, 'Authentication required');
return;
}
try {
const payload = verify(token, process.env.JWT_SECRET!) as { userId: string };
const authWs = ws as AuthenticatedWebSocket;
authWs.userId = payload.userId;
authWs.subscriptions = new Set();
authWs.lastActivity = Date.now();
this.setupMessageHandler(authWs);
this.setupCloseHandler(authWs);
this.setupErrorHandler(authWs);
authWs.send(JSON.stringify({
type: 'connected',
userId: payload.userId,
timestamp: Date.now()
}));
} catch (error) {
ws.close(1008, 'Invalid token');
}
});
}
private setupMessageHandler(ws: AuthenticatedWebSocket): void {
ws.on('message', (data: Buffer) => {
ws.lastActivity = Date.now();
try {
const message: Message = JSON.parse(data.toString());
switch (message.type) {
case 'subscribe':
this.handleSubscribe(ws, message.channel!);
break;
case 'unsubscribe':
this.handleUnsubscribe(ws, message.channel!);
break;
case 'publish':
this.handlePublish(ws, message.channel!, message.data);
break;
case 'ping':
ws.send(JSON.stringify({ type: 'pong', timestamp: Date.now() }));
break;
}
} catch (error) {
ws.send(JSON.stringify({
type: 'error',
message: 'Invalid message format',
timestamp: Date.now()
}));
}
});
}
private handleSubscribe(ws: AuthenticatedWebSocket, channel: string): void {
if (!this.channels.has(channel)) {
this.channels.set(channel, new Set());
}
this.channels.get(channel)!.add(ws);
ws.subscriptions.add(channel);
ws.send(JSON.stringify({
type: 'subscribed',
channel,
timestamp: Date.now()
}));
}
private handleUnsubscribe(ws: AuthenticatedWebSocket, channel: string): void {
const subscribers = this.channels.get(channel);
if (subscribers) {
subscribers.delete(ws);
if (subscribers.size === 0) {
this.channels.delete(channel);
}
}
ws.subscriptions.delete(channel);
}
private handlePublish(ws: AuthenticatedWebSocket, channel: string, data: unknown): void {
const subscribers = this.channels.get(channel);
if (!subscribers) return;
const message = JSON.stringify({
type: 'message',
channel,
data,
userId: ws.userId,
timestamp: Date.now()
});
subscribers.forEach(subscriber => {
if (subscriber.readyState === WebSocket.OPEN) {
subscriber.send(message);
}
});
}
private setupCloseHandler(ws: AuthenticatedWebSocket): void {
ws.on('close', () => {
ws.subscriptions.forEach(channel => {
this.handleUnsubscribe(ws, channel);
});
});
}
private setupErrorHandler(ws: AuthenticatedWebSocket): void {
ws.on('error', (error) => {
console.error(`WebSocket error for user ${ws.userId}:`, error);
});
}
private startHeartbeat(): void {
this.heartbeatInterval = setInterval(() => {
const now = Date.now();
this.wss.clients.forEach((ws) => {
const authWs = ws as AuthenticatedWebSocket;
if (now - authWs.lastActivity > this.CONNECTION_TIMEOUT) {
authWs.terminate();
return;
}
if (ws.readyState === WebSocket.OPEN) {
ws.ping();
}
});
}, this.HEARTBEAT_INTERVAL);
}
private extractToken(req: IncomingMessage): string | null {
const url = new URL(req.url!, `http://${req.headers.host}`);
return url.searchParams.get('token');
}
public shutdown(): void {
clearInterval(this.heartbeatInterval);
this.wss.close();
}
}
const server = new RealtimeServer(8080);
This implementation handles authentication during the WebSocket handshake, manages channel subscriptions with memory-efficient data structures, implements heartbeat monitoring to detect dead connections, and uses per-message deflate compression to reduce bandwidth.
Client-Side WebSocket Implementation with Reconnection
Client implementations must handle network interruptions, implement exponential backoff, and queue messages during disconnection:
class RealtimeClient {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private readonly MAX_RECONNECT_ATTEMPTS = 10;
private readonly BASE_RECONNECT_DELAY = 1000;
private messageQueue: Array<object> = [];
private subscriptions: Set<string> = new Set();
private eventHandlers: Map<string, Set<Function>> = new Map();
constructor(
private url: string,
private token: string
) {
this.connect();
}
private connect(): void {
const wsUrl = `${this.url}?token=${this.token}`;
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('WebSocket connected');
this.reconnectAttempts = 0;
this.flushMessageQueue();
this.resubscribe();
};
this.ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
this.handleMessage(message);
} catch (error) {
console.error('Failed to parse message:', error);
}
};
this.ws.onclose = (event) => {
console.log('WebSocket closed:', event.code, event.reason);
this.handleReconnect();
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
}
private handleReconnect(): void {
if (this.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) {
console.error('Max reconnection attempts reached');
this.emit('maxReconnectAttemptsReached', {});
return;
}
const delay = Math.min(
this.BASE_RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts),
30000
);
this.reconnectAttempts++;
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
setTimeout(() => this.connect(), delay);
}
private flushMessageQueue(): void {
while (this.messageQueue.length > 0) {
const message = this.messageQueue.shift()!;
this.send(message);
}
}
private resubscribe(): void {
this.subscriptions.forEach(channel => {
this.send({ type: 'subscribe', channel, timestamp: Date.now() });
});
}
public subscribe(channel: string, handler: Function): void {
this.subscriptions.add(channel);
if (!this.eventHandlers.has(channel)) {
this.eventHandlers.set(channel, new Set());
}
this.eventHandlers.get(channel)!.add(handler);
if (this.isConnected()) {
this.send({ type: 'subscribe', channel, timestamp: Date.now() });
}
}
public unsubscribe(channel: string, handler?: Function): void {
if (handler) {
this.eventHandlers.get(channel)?.delete(handler);
} else {
this.eventHandlers.delete(channel);
}
if (this.eventHandlers.get(channel)?.size === 0) {
this.subscriptions.delete(channel);
if (this.isConnected()) {
this.send({ type: 'unsubscribe', channel, timestamp: Date.now() });
}
}
}
public publish(channel: string, data: unknown): void {
this.send({
type: 'publish',
channel,
data,
timestamp: Date.now()
});
}
private send(message: object): void {
if (this.isConnected()) {
this.ws!.send(JSON.stringify(message));
} else {
this.messageQueue.push(message);
}
}
private handleMessage(message: any): void {
if (message.type === 'message' && message.channel) {
this.emit(message.channel, message.data);
} else if (message.type === 'error') {
console.error('Server error:', message.message);
}
}
private emit(event: string, data: any): void {
const handlers = this.eventHandlers.get(event);
if (handlers) {
handlers.forEach(handler => handler(data));
}
}
private isConnected(): boolean {
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
}
public disconnect(): void {
if (this.ws) {
this.ws.close(1000, 'Client disconnect');
this.ws = null;
}
}
}
This client implementation queues messages during disconnection, automatically resubscribes to channels after reconnection, implements exponential backoff to prevent server overload, and provides a clean event-driven API for application code.
Scaling WebSocket Connections Across Multiple Servers
Single-server WebSocket implementations hit connection limits around 50,000-100,000 concurrent connections depending on hardware. Horizontal scaling requires message distribution across server instances.
Redis Pub/Sub provides a lightweight solution for broadcasting messages across WebSocket servers:
import Redis from 'ioredis';
class DistributedRealtimeServer extends RealtimeServer {
private redis: Redis;
private redisSub: Redis;
constructor(port: number, redisUrl: string) {
super(port);
this.redis = new Redis(redisUrl);
this.redisSub = new Redis(redisUrl);
this.setupRedisSubscription();
}
private setupRedisSubscription(): void {
this.redisSub.psubscribe('channel:*', (err, count) => {
if (err) {
console.error('Redis subscription error:', err);
}
});
this.redisSub.on('pmessage', (pattern, channel, message) => {
const channelName = channel.replace('channel:', '');
const subscribers = this.channels.get(channelName);
if (subscribers) {
subscribers.forEach(ws => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(message);
}
});
}
});
}
protected handlePublish(
ws: AuthenticatedWebSocket,
channel: string,
data: unknown
): void {
const message = JSON.stringify({
type: 'message',
channel,
data,
userId: ws.userId,
timestamp: Date.now()
});
this.redis.publish(`channel:${channel}`, message);
}
}
This pattern distributes messages across all server instances. When a client publishes to a channel, the message goes to Redis, which broadcasts to all subscribed servers, which then forward to their connected clients.
For larger deployments, consider NATS or Apache Kafka for message distribution. NATS provides lower latency than Redis for high-throughput scenarios, while Kafka offers message persistence and replay capabilities essential for audit trails and debugging.
Common Pitfalls and Failure Modes
Memory leaks from unclosed subscriptions: Applications that subscribe to channels without proper cleanup accumulate memory over time. Always unsubscribe when components unmount or users navigate away. Implement server-side subscription limits per connection to prevent abuse.
Message ordering violations: WebSocket guarantees ordering within a connection but not across connections. If a user has multiple tabs open, messages may arrive out of order. Implement sequence numbers and server-side ordering when strict ordering matters.
Authentication token expiration: Tokens embedded in WebSocket handshakes cannot be refreshed without reconnecting. Implement proactive reconnection before token expiry or use short-lived tokens with frequent reconnection cycles.
Thundering herd during reconnection: When servers restart, thousands of clients reconnect simultaneously, overwhelming the server. Implement client-side jitter in reconnection delays—randomize the backoff delay by ±25% to spread reconnection load.
Binary data handling: Sending JSON-encoded binary data wastes bandwidth. Use binary frames for images, files, or protocol buffers. The WebSocket API supports ArrayBuffer and Blob types natively.
Connection state synchronization: Clients disconnecting and reconnecting may miss messages. Implement message sequence numbers and allow clients to request missed messages since their last known sequence number.
Load balancer configuration: Many load balancers terminate idle connections after 60 seconds. Configure load balancer idle timeouts to match or exceed your heartbeat interval. AWS ALB requires explicit WebSocket support configuration.
Best Practices for Production WebSocket Systems
Implement comprehensive monitoring: Track connection count, message throughput, reconnection rate, and message latency. Alert on abnormal reconnection spikes indicating network issues or server problems.
Use compression selectively: Per-message deflate compression reduces bandwidth but increases CPU usage. Enable compression only for text messages larger than 1KB. Binary data is often already compressed.
Design for idempotency: Network issues may cause duplicate message delivery. Include message IDs and implement deduplication logic on both client and server.
Implement rate limiting: Prevent abuse by limiting messages per connection per second. Use token bucket algorithms to allow bursts while preventing sustained overload.
Separate control and data planes: Use different channels for control messages (subscribe/unsubscribe) and data messages. This prevents control message delays during high data throughput.
Plan for graceful degradation: When WebSocket connections fail, fall back to HTTP polling or SSE. Detect WebSocket support and connection quality, degrading gracefully rather than failing completely.
Implement connection draining: During deployments, stop accepting new connections while allowing existing connections to complete naturally. This prevents abrupt disconnections and improves user experience.
Use sticky sessions carefully: Load balancers must route all requests from a client to the same server. However, sticky sessions prevent even load distribution. Consider using Redis-backed session sharing instead.
Frequently Asked Questions
What is the maximum number of concurrent WebSocket connections a single server can handle?
A single Node.js