Topic Overview

WebSockets

Learn WebSocket protocol for full-duplex, persistent communication between client and server. Understand handshake, frame format, and use cases.


What are WebSockets?

WebSocket provides:

  • Full-duplex communication: Both client and server can send data simultaneously
  • Persistent connection: Connection stays open (unlike HTTP)
  • Low overhead: Minimal protocol overhead after handshake
  • Real-time: Instant bidirectional communication

Use cases:

  • Real-time chat applications
  • Live notifications
  • Collaborative editing
  • Gaming
  • Financial tickers
  • Live sports scores

WebSocket vs HTTP

HTTP (Request-Response)

Client → Server: GET /api/data
Server → Client: Response (connection closes)

For updates: Client must poll repeatedly
  Client → Server: GET /api/data (poll 1)
  Client → Server: GET /api/data (poll 2)
  Client → Server: GET /api/data (poll 3)

Problems:

  • Polling overhead: Repeated requests
  • Latency: Delay between polls
  • Server load: Many unnecessary requests

WebSocket (Persistent Connection)

Client ↔ Server: Persistent connection
  Client → Server: Message 1
  Server → Client: Message 2
  Client → Server: Message 3
  Server → Client: Message 4
  (Connection stays open)

Benefits:

  • No polling: Server can push data immediately
  • Low latency: Instant communication
  • Efficient: Single connection, minimal overhead

WebSocket Handshake

WebSocket starts as HTTP request, then upgrades to WebSocket protocol.

Client Request

GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://example.com

Key headers:

  • Upgrade: websocket
  • Connection: Upgrade
  • Sec-WebSocket-Key: Random base64-encoded key
  • Sec-WebSocket-Version: Protocol version (13)

Server Response

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Key points:

  • Status 101: Switching Protocols
  • Sec-WebSocket-Accept: Computed from client key + magic string

Key Calculation

import hashlib
import base64

def compute_accept_key(client_key):
    """Compute Sec-WebSocket-Accept from client key"""
    magic_string = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
    combined = client_key + magic_string
    sha1_hash = hashlib.sha1(combined.encode()).digest()
    return base64.b64encode(sha1_hash).decode()

# Example
client_key = "dGhlIHNhbXBsZSBub25jZQ=="
accept_key = compute_accept_key(client_key)
# Result: "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="

WebSocket Frame Format

After handshake, data sent in frames:

 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 ...                |
+---------------------------------------------------------------+

Fields:

  • FIN: Final fragment (1) or more fragments (0)
  • RSV: Reserved bits
  • Opcode: Frame type (text, binary, close, ping, pong)
  • MASK: Masking key present (client must mask, server must not)
  • Payload length: Length of payload data
  • Masking key: 32-bit key (if MASK=1)
  • Payload data: Actual data

Examples

WebSocket Server (Node.js)

const WebSocket = require('ws');

const wss = new WebSocket.Server({ port: 8080 });

wss.on('connection', (ws, req) => {
  console.log('Client connected');
  
  // Send welcome message
  ws.send('Welcome to WebSocket server!');
  
  // Handle messages
  ws.on('message', (message) => {
    console.log(`Received: ${message}`);
    
    // Echo back
    ws.send(`Echo: ${message}`);
  });
  
  // Handle close
  ws.on('close', () => {
    console.log('Client disconnected');
  });
  
  // Handle errors
  ws.on('error', (error) => {
    console.error('WebSocket error:', error);
  });
});

WebSocket Client (JavaScript)

// Create WebSocket connection
const ws = new WebSocket('ws://localhost:8080');

// Connection opened
ws.onopen = () => {
  console.log('Connected to server');
  ws.send('Hello Server!');
};

// Receive message
ws.onmessage = (event) => {
  console.log('Received:', event.data);
};

// Connection closed
ws.onclose = () => {
  console.log('Connection closed');
};

// Error
ws.onerror = (error) => {
  console.error('WebSocket error:', error);
};

// Send message
function sendMessage(message) {
  if (ws.readyState === WebSocket.OPEN) {
    ws.send(message);
  }
}

WebSocket with Authentication

const WebSocket = require('ws');
const jwt = require('jsonwebtoken');

const wss = new WebSocket.Server({ 
  port: 8080,
  verifyClient: (info) => {
    // Verify token from query string or headers
    const token = new URL(info.req.url, 'http://localhost').searchParams.get('token');
    
    try {
      jwt.verify(token, 'secret');
      return true;
    } catch {
      return false;
    }
  }
});

wss.on('connection', (ws, req) => {
  // Extract user from token
  const token = new URL(req.url, 'http://localhost').searchParams.get('token');
  const user = jwt.decode(token);
  
  console.log(`User ${user.id} connected`);
  
  ws.on('message', (message) => {
    // Broadcast to all clients
    wss.clients.forEach((client) => {
      if (client !== ws && client.readyState === WebSocket.OPEN) {
        client.send(`${user.id}: ${message}`);
      }
    });
  });
});

WebSocket Reconnection

class WebSocketClient {
  constructor(url) {
    this.url = url;
    this.ws = null;
    this.reconnectInterval = 1000;
    this.maxReconnectInterval = 30000;
    this.reconnectDecay = 1.5;
    this.shouldReconnect = true;
  }
  
  connect() {
    this.ws = new WebSocket(this.url);
    
    this.ws.onopen = () => {
      console.log('Connected');
      this.reconnectInterval = 1000; // Reset on successful connection
    };
    
    this.ws.onclose = () => {
      if (this.shouldReconnect) {
        this.reconnect();
      }
    };
    
    this.ws.onerror = (error) => {
      console.error('WebSocket error:', error);
    };
  }
  
  reconnect() {
    setTimeout(() => {
      console.log('Reconnecting...');
      this.connect();
      this.reconnectInterval = Math.min(
        this.reconnectInterval * this.reconnectDecay,
        this.maxReconnectInterval
      );
    }, this.reconnectInterval);
  }
  
  send(data) {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(data);
    }
  }
  
  close() {
    this.shouldReconnect = false;
    if (this.ws) {
      this.ws.close();
    }
  }
}

// Usage
const client = new WebSocketClient('ws://localhost:8080');
client.connect();

Common Pitfalls

  • Not handling reconnection: Connection drops, client doesn't reconnect. Fix: Implement automatic reconnection with exponential backoff
  • No heartbeat/ping: Don't know if connection is alive. Fix: Send ping frames periodically, close if no pong
  • Not handling backpressure: Sending too fast, overwhelming server. Fix: Check bufferedAmount, throttle sending
  • Security issues: Not validating origin, no authentication. Fix: Validate origin, implement authentication
  • Memory leaks: Not cleaning up event listeners. Fix: Remove listeners on close, limit connection lifetime
  • Not handling frame fragmentation: Large messages split into multiple frames. Fix: Handle FIN bit, reassemble fragments

Interview Questions

Beginner

Q: What are WebSockets and why are they used instead of HTTP?

A:

WebSockets provide full-duplex, persistent connections between client and server.

Why used instead of HTTP:

  1. Real-time communication: Server can push data immediately (no polling)
  2. Lower latency: No HTTP overhead after handshake
  3. Efficient: Single persistent connection vs multiple HTTP requests
  4. Bidirectional: Both client and server can send data simultaneously

HTTP vs WebSocket:

HTTP (Request-Response):
  Client → Server: GET /api/data
  Server → Client: Response (connection closes)
  For updates: Must poll repeatedly

WebSocket (Persistent):
  Client ↔ Server: Persistent connection
  Server → Client: Push data immediately
  No polling needed

Use cases:

  • Real-time chat
  • Live notifications
  • Collaborative editing
  • Gaming
  • Financial tickers

Intermediate

Q: Explain the WebSocket handshake process and frame format.

A:

Handshake Process:

  1. Client Request (HTTP Upgrade)

    GET /chat HTTP/1.1
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
    Sec-WebSocket-Version: 13
    
  2. Server Response (101 Switching Protocols)

    HTTP/1.1 101 Switching Protocols
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
    
  3. Key Calculation

    accept_key = base64(sha1(client_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"))
    

Frame Format:

[FIN|RSV|Opcode|MASK|Payload Length|Masking Key|Payload Data]

Fields:

  • FIN: Final fragment (1) or more fragments (0)
  • Opcode: Frame type (0x1=text, 0x2=binary, 0x8=close, 0x9=ping, 0xA=pong)
  • MASK: Masking key present (client must mask, server must not)
  • Payload length: Length of data (7, 16, or 64 bits)
  • Masking key: 32-bit key (if MASK=1, client frames)
  • Payload data: Actual message data

After handshake:

  • Connection upgraded to WebSocket protocol
  • Data sent in frames (not HTTP)
  • Connection stays open until closed

Senior

Q: Design a scalable WebSocket system that handles millions of concurrent connections. How do you handle load balancing, message routing, and connection management?

A:

class ScalableWebSocketSystem {
  private servers: WebSocketServer[];
  private loadBalancer: LoadBalancer;
  private messageRouter: MessageRouter;
  private connectionManager: ConnectionManager;
  private pubsub: PubSub;
  
  constructor() {
    // Multiple WebSocket servers
    this.servers = [
      new WebSocketServer({ port: 8080 }),
      new WebSocketServer({ port: 8081 }),
      new WebSocketServer({ port: 8082 })
    ];
    
    this.loadBalancer = new LoadBalancer();
    this.messageRouter = new MessageRouter();
    this.connectionManager = new ConnectionManager();
    this.pubsub = new PubSub(); // Redis Pub/Sub
  }
  
  // 1. Load Balancing
  class LoadBalancer {
    async routeConnection(request: Request): Promise<WebSocketServer> {
      // Sticky sessions: Route same client to same server
      const clientId = this.getClientId(request);
      const server = this.getServerForClient(clientId);
      
      // Or: Least connections
      const server = this.servers.reduce((min, s) => 
        s.connectionCount < min.connectionCount ? s : min
      );
      
      return server;
    }
  }
  
  // 2. Connection Management
  class ConnectionManager {
    private connections: Map<string, Connection>; // user_id → connection
    
    async handleConnection(ws: WebSocket, userId: string): Promise<void> {
      // Store connection
      this.connections.set(userId, {
        ws,
        serverId: this.getServerId(),
        connectedAt: Date.now()
      });
      
      // Subscribe to user's channels
      await this.pubsub.subscribe(`user:${userId}`, (message) => {
        this.sendToUser(userId, message);
      });
      
      // Handle disconnection
      ws.on('close', () => {
        this.connections.delete(userId);
        this.pubsub.unsubscribe(`user:${userId}`);
      });
    }
    
    async sendToUser(userId: string, message: any): Promise<void> {
      const connection = this.connections.get(userId);
      if (connection && connection.ws.readyState === WebSocket.OPEN) {
        connection.ws.send(JSON.stringify(message));
      }
    }
  }
  
  // 3. Message Routing (Cross-Server)
  class MessageRouter {
    async routeMessage(fromUserId: string, toUserId: string, message: any): Promise<void> {
      // Check if user connected to this server
      const connection = this.connectionManager.getConnection(toUserId);
      
      if (connection && connection.serverId === this.getServerId()) {
        // Local: Send directly
        await this.connectionManager.sendToUser(toUserId, message);
      } else {
        // Remote: Publish to pub/sub
        await this.pubsub.publish(`user:${toUserId}`, message);
      }
    }
  }
  
  // 4. Pub/Sub for Cross-Server Communication
  class PubSub {
    async publish(channel: string, message: any): Promise<void> {
      // Publish to Redis
      await this.redis.publish(channel, JSON.stringify(message));
    }
    
    async subscribe(channel: string, callback: Function): Promise<void> {
      // Subscribe to Redis channel
      this.redis.subscribe(channel);
      this.redis.on('message', (ch, msg) => {
        if (ch === channel) {
          callback(JSON.parse(msg));
        }
      });
    }
  }
  
  // 5. Connection Pooling
  class ConnectionPool {
    private pools: Map<string, WebSocket[]>; // server → connections
    
    async getConnection(server: string): Promise<WebSocket> {
      let pool = this.pools.get(server);
      if (!pool) {
        pool = [];
        this.pools.set(server, pool);
      }
      
      // Reuse existing connection
      const connection = pool.find(ws => ws.readyState === WebSocket.OPEN);
      if (connection) {
        return connection;
      }
      
      // Create new connection
      const ws = new WebSocket(`ws://${server}`);
      pool.push(ws);
      return ws;
    }
  }
  
  // 6. Heartbeat/Ping
  class HeartbeatManager {
    startHeartbeat(ws: WebSocket): void {
      const interval = setInterval(() => {
        if (ws.readyState === WebSocket.OPEN) {
          ws.ping();
        } else {
          clearInterval(interval);
        }
      }, 30000); // 30 seconds
      
      ws.on('pong', () => {
        // Connection alive
      });
      
      ws.on('close', () => {
        clearInterval(interval);
      });
    }
  }
  
  // 7. Rate Limiting
  class RateLimiter {
    async checkRateLimit(userId: string): Promise<boolean> {
      const key = `ratelimit:${userId}`;
      const count = await this.redis.incr(key);
      
      if (count === 1) {
        await this.redis.expire(key, 60); // 1 minute window
      }
      
      return count <= 100; // 100 messages per minute
    }
  }
}

Features:

  1. Load balancing: Distribute connections across servers
  2. Message routing: Route messages to correct server
  3. Pub/Sub: Cross-server communication via Redis
  4. Connection management: Track connections, handle disconnections
  5. Heartbeat: Keep connections alive, detect dead connections
  6. Rate limiting: Prevent abuse
  7. Scaling: Horizontal scaling with multiple servers

Key Takeaways

  • WebSocket: Full-duplex, persistent connection for real-time communication
  • Handshake: Starts as HTTP, upgrades to WebSocket (101 Switching Protocols)
  • Frame format: Binary frames with opcode, mask, payload length, data
  • Benefits: No polling, low latency, efficient, bidirectional
  • Use cases: Real-time chat, notifications, gaming, collaborative editing
  • Connection management: Handle reconnection, heartbeat, backpressure
  • Scaling: Load balancing, message routing, pub/sub for cross-server communication
  • Best practices: Implement reconnection, heartbeat, rate limiting, authentication

About the author

InterviewCrafted helps you master system design with patience. We believe in curiosity-led engineering, reflective writing, and designing systems that make future changes feel calm.