WebSockets: Real-Time Bidirectional Communication

Understanding WebSockets - the protocol that enables full-duplex communication channels over a single TCP connection.

Best viewed on desktop for optimal interactive experience

WebSockets

WebSockets provide a persistent, full-duplex communication channel between client and server. Unlike traditional HTTP, WebSockets maintain an open connection, allowing both parties to send messages at any time - like having an always-open phone line rather than sending letters back and forth.

Interactive Demonstration

Experience real-time bidirectional communication with WebSockets:

WebSocket Controls

WebSocket Connection

CLIENT
SERVER
Disconnected
Status
Offline
Sent
0
Received
0
Avg Latency
0ms
Throughput
0

Activity Log

WebSocket Advantages

True Real-time

Instant bidirectional communication with ~0ms latency

Persistent Connection

Single TCP connection for entire session

Low Overhead

2-14 bytes per frame vs 700+ bytes HTTP headers

Full Duplex

Client and server can send messages simultaneously

How WebSockets Work

The Handshake Process

WebSocket connections start with an HTTP upgrade handshake:

GET /socket HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13 Origin: http://example.com HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Connection Lifecycle

Client Server | | |--- HTTP Upgrade Request ----->| | | |<-- 101 Switching Protocols ---| | | |========= WS Connection ========| | | |<------- Message Frame -------->| |<------- Message Frame -------->| |<------- Message Frame -------->| | (bidirectional) | | | |--- Close Frame (1000) -------->| |<-- Close Frame (1000) ---------| | | |========= Disconnected =========|

WebSocket Frame Structure

Frame Format

Frame = [FIN|RSV|Opcode|Mask|Length|MaskKey|Payload]
0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-------+-+-------------+-------------------------------+ |F|R|R|R| opcode|M| Payload len | Extended payload length | |I|S|S|S| (4) |A| (7) | (16/64) | |N|V|V|V| |S| | (if payload len==126/127) | | |1|2|3| |K| | | +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - + | Extended payload length continued, if payload len == 127 | + - - - - - - - - - - - - - - - +-------------------------------+ | |Masking-key, if MASK set to 1 | +-------------------------------+-------------------------------+ | Masking-key (continued) | Payload Data | +-------------------------------- - - - - - - - - - - - - - - - + : Payload Data continued ... : + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + | Payload Data continued ... | +---------------------------------------------------------------+

Opcode Types

OpcodeMeaningDescription
0x0ContinuationPart of fragmented message
0x1TextUTF-8 text data
0x2BinaryBinary data
0x8CloseConnection close
0x9PingHeartbeat request
0xAPongHeartbeat response

Implementation

Client-Side WebSocket

class WebSocketClient { constructor(url) { this.url = url; this.ws = null; this.reconnectDelay = 1000; this.maxReconnectDelay = 30000; this.reconnectAttempts = 0; this.messageQueue = []; this.listeners = new Map(); } connect() { return new Promise((resolve, reject) => { this.ws = new WebSocket(this.url); this.ws.onopen = () => { console.log('WebSocket connected'); this.reconnectAttempts = 0; this.reconnectDelay = 1000; this.flushQueue(); resolve(); }; this.ws.onmessage = (event) => { this.handleMessage(event.data); }; this.ws.onerror = (error) => { console.error('WebSocket error:', error); reject(error); }; this.ws.onclose = (event) => { console.log(`WebSocket closed: ${event.code} - ${event.reason}`); this.handleDisconnection(event); }; }); } handleMessage(data) { try { const message = JSON.parse(data); const listeners = this.listeners.get(message.type) || []; listeners.forEach(callback => callback(message)); } catch (error) { console.error('Failed to parse message:', error); } } on(type, callback) { if (!this.listeners.has(type)) { this.listeners.set(type, []); } this.listeners.get(type).push(callback); } send(type, data) { const message = JSON.stringify({ type, data, timestamp: Date.now() }); if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(message); } else { // Queue message for later this.messageQueue.push(message); } } flushQueue() { while (this.messageQueue.length > 0 && this.ws?.readyState === WebSocket.OPEN) { const message = this.messageQueue.shift(); this.ws.send(message); } } handleDisconnection(event) { // Clean close (code 1000) - don't reconnect if (event.code === 1000) return; // Attempt reconnection with exponential backoff this.reconnectAttempts++; const delay = Math.min( this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), this.maxReconnectDelay ); console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`); setTimeout(() => { this.connect().catch(error => { console.error('Reconnection failed:', error); }); }, delay); } close() { if (this.ws) { this.ws.close(1000, 'Normal closure'); } } }

Server-Side WebSocket (Node.js)

const WebSocket = require('ws'); class WebSocketServer { constructor(port) { this.wss = new WebSocket.Server({ port }); this.clients = new Map(); this.setupServer(); } setupServer() { this.wss.on('connection', (ws, req) => { const clientId = this.generateClientId(); const client = { id: clientId, ws: ws, ip: req.socket.remoteAddress, connectedAt: Date.now(), isAlive: true }; this.clients.set(clientId, client); console.log(`Client ${clientId} connected`); // Setup ping/pong for connection health ws.on('pong', () => { client.isAlive = true; }); ws.on('message', (data) => { this.handleMessage(clientId, data); }); ws.on('close', (code, reason) => { console.log(`Client ${clientId} disconnected: ${code} - ${reason}`); this.clients.delete(clientId); }); ws.on('error', (error) => { console.error(`Client ${clientId} error:`, error); }); // Send welcome message this.sendToClient(clientId, 'welcome', { clientId, connectedClients: this.clients.size }); }); // Heartbeat interval setInterval(() => { this.wss.clients.forEach((ws) => { const client = [...this.clients.values()].find(c => c.ws === ws); if (client && !client.isAlive) { console.log(`Client ${client.id} failed ping check`); return ws.terminate(); } if (client) { client.isAlive = false; ws.ping(); } }); }, 30000); } handleMessage(clientId, data) { try { const message = JSON.parse(data); console.log(`Message from ${clientId}:`, message); // Echo back to sender this.sendToClient(clientId, 'echo', message); // Broadcast to others if (message.broadcast) { this.broadcast(message, clientId); } } catch (error) { console.error('Failed to parse message:', error); } } sendToClient(clientId, type, data) { const client = this.clients.get(clientId); if (client && client.ws.readyState === WebSocket.OPEN) { client.ws.send(JSON.stringify({ type, data, timestamp: Date.now() })); } } broadcast(message, excludeClientId = null) { this.clients.forEach((client, clientId) => { if (clientId !== excludeClientId && client.ws.readyState === WebSocket.OPEN) { client.ws.send(JSON.stringify({ type: 'broadcast', data: message, from: excludeClientId, timestamp: Date.now() })); } }); } generateClientId() { return `client_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } }

Performance Characteristics

Overhead Comparison

OverheadHTTP = Headersrequest + Headersresponse ≈ 1KB
OverheadWebSocket = Handshake + FrameHeaders ≈ 2-14 bytes

For 1000 messages:

  • HTTP Polling: 1000 × 1KB = 1MB overhead
  • WebSocket: 2B + (1000 × 6B) ≈ 6KB overhead
  • Improvement: 170× less overhead

Latency Analysis

MetricHTTP PollingLong PollingWebSocket
Message Latency0-interval~0ms (waiting)~0ms (always)
Round Trip TimeFull HTTP cycleFull HTTP cycleFrame only
Connection SetupEvery requestEvery requestOnce
BidirectionalNoNoYes

Scaling WebSockets

Challenges

  1. Connection Limits: Each WebSocket maintains a TCP connection
  2. Memory Usage: ~10-50KB per connection
  3. Load Balancing: Sticky sessions required
  4. Horizontal Scaling: Message routing complexity

Solutions

1. Connection Pooling

class WebSocketPool { constructor(maxConnections = 10000) { this.connections = new Map(); this.maxConnections = maxConnections; } canAccept() { return this.connections.size < this.maxConnections; } add(clientId, ws) { if (!this.canAccept()) { ws.close(1013, 'Try again later'); return false; } this.connections.set(clientId, ws); return true; } }

2. Message Broker Architecture

// Using Redis for pub/sub across servers const redis = require('redis'); class ScalableWebSocketServer { constructor() { this.publisher = redis.createClient(); this.subscriber = redis.createClient(); this.localClients = new Map(); this.subscriber.subscribe('global-messages'); this.subscriber.on('message', (channel, message) => { this.handleGlobalMessage(JSON.parse(message)); }); } broadcastGlobal(message) { // Send to local clients this.localClients.forEach(client => { client.send(JSON.stringify(message)); }); // Publish to other servers this.publisher.publish('global-messages', JSON.stringify(message)); } handleGlobalMessage(message) { // Received from another server this.localClients.forEach(client => { client.send(JSON.stringify(message)); }); } }

Security Considerations

1. Authentication

// Token-based auth const ws = new WebSocket('wss://api.example.com/socket', { headers: { 'Authorization': 'Bearer ' + token } }); // Or via query parameter const ws = new WebSocket('wss://api.example.com/socket?token=' + token);

2. Origin Validation

wss.on('connection', (ws, req) => { const origin = req.headers.origin; if (!isValidOrigin(origin)) { ws.close(1008, 'Invalid origin'); return; } // Continue with connection });

3. Rate Limiting

class RateLimiter { constructor(maxMessages = 100, windowMs = 60000) { this.clients = new Map(); this.maxMessages = maxMessages; this.windowMs = windowMs; } check(clientId) { const now = Date.now(); const client = this.clients.get(clientId) || { messages: [], blocked: false }; // Remove old messages client.messages = client.messages.filter(t => t > now - this.windowMs); if (client.messages.length >= this.maxMessages) { client.blocked = true; return false; } client.messages.push(now); this.clients.set(clientId, client); return true; } }

Common Patterns

1. Heartbeat/Keepalive

class HeartbeatWebSocket { constructor(url) { this.url = url; this.pingInterval = 30000; this.pongTimeout = 5000; } startHeartbeat() { this.pingTimer = setInterval(() => { if (this.ws.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: 'ping' })); this.pongTimer = setTimeout(() => { console.error('Pong timeout - connection lost'); this.ws.close(); this.reconnect(); }, this.pongTimeout); } }, this.pingInterval); } handleMessage(data) { const message = JSON.parse(data); if (message.type === 'pong') { clearTimeout(this.pongTimer); return; } // Handle other messages } }

2. Message Acknowledgment

class ReliableWebSocket { constructor() { this.messageId = 0; this.pendingAcks = new Map(); this.ackTimeout = 5000; } sendReliable(data) { const messageId = this.messageId++; const message = { id: messageId, data, timestamp: Date.now() }; this.ws.send(JSON.stringify(message)); // Wait for acknowledgment const timer = setTimeout(() => { console.error(`Message ${messageId} not acknowledged`); this.resend(message); }, this.ackTimeout); this.pendingAcks.set(messageId, { message, timer }); } handleAck(messageId) { const pending = this.pendingAcks.get(messageId); if (pending) { clearTimeout(pending.timer); this.pendingAcks.delete(messageId); } } }

Use Cases

Perfect For ✅

  1. Real-time Collaboration

    • Google Docs
    • Figma
    • VS Code Live Share
  2. Live Updates

    • Stock tickers
    • Sports scores
    • News feeds
  3. Gaming

    • Multiplayer games
    • Turn-based games
    • Game state sync
  4. Communication

    • Chat applications
    • Video call signaling
    • Presence indicators

Not Ideal For ❌

  1. Simple Request-Response

    • REST APIs
    • Form submissions
    • File uploads
  2. Cacheable Content

    • Static resources
    • CDN-friendly data
    • Infrequent updates

Debugging WebSockets

Chrome DevTools

  1. Open Network tab
  2. Filter by "WS"
  3. Click on WebSocket connection
  4. View frames in Messages tab

Logging Strategy

class DebugWebSocket { constructor(url, debug = true) { this.debug = debug; this.ws = new WebSocket(url); this.setupLogging(); } setupLogging() { const original = { send: this.ws.send.bind(this.ws) }; this.ws.send = (data) => { if (this.debug) { console.log('⬆️ Sending:', data); } original.send(data); }; this.ws.onmessage = (event) => { if (this.debug) { console.log('⬇️ Received:', event.data); } this.handleMessage(event); }; } }

Conclusion

WebSockets revolutionize real-time web communication by providing persistent, bidirectional channels with minimal overhead. While they require more complex infrastructure than traditional HTTP, the benefits of instant communication and reduced bandwidth make them indispensable for modern real-time applications. Understanding when and how to implement WebSockets is crucial for building responsive, interactive web experiences.

If you found this explanation helpful, consider sharing it with others.

Mastodon