Kenneth Au

Building Real-Time Applications with WebSockets

Real-time features — live chat, collaborative editing, real-time dashboards, stock tickers — require a persistent connection between client and server. HTTP’s request-response model wasn’t designed for this. WebSockets provide a full-duplex, persistent connection that lets both sides send messages at any time.

How WebSockets Work

  1. The client sends an HTTP request with an Upgrade: websocket header
  2. The server responds with 101 Switching Protocols
  3. The TCP connection stays open
  4. Both sides can send messages (frames) at any time until one side closes the connection

This upgrade happens once. After that, there’s no HTTP overhead — just lightweight binary or text frames.

Building a WebSocket Server with Node.js

Using the ws library:

npm install ws
const WebSocket = require('ws');

const server = new WebSocket.Server({ port: 8080 });
const clients = new Map();

server.on('connection', (ws, req) => {
  const id = generateId();
  clients.set(id, ws);

  ws.on('message', (data) => {
    const message = JSON.parse(data);

    switch (message.type) {
      case 'chat':
        // Broadcast to all connected clients
        broadcast({
          type: 'chat',
          from: id,
          text: message.text,
          timestamp: Date.now(),
        });
        break;

      case 'ping':
        ws.send(JSON.stringify({ type: 'pong' }));
        break;
    }
  });

  ws.on('close', () => {
    clients.delete(id);
    broadcast({ type: 'user_left', userId: id });
  });

  // Send welcome message
  ws.send(
    JSON.stringify({
      type: 'connected',
      userId: id,
      userCount: clients.size,
    }),
  );

  // Notify others
  broadcast({ type: 'user_joined', userId: id });
});

function broadcast(message) {
  const data = JSON.stringify(message);
  for (const [id, client] of clients) {
    if (client.readyState === WebSocket.OPEN) {
      client.send(data);
    }
  }
}

function generateId() {
  return Math.random().toString(36).substring(2, 9);
}

The Client Side

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

ws.onopen = () => {
  console.log('Connected to server');
  ws.send(JSON.stringify({ type: 'chat', text: 'Hello, world!' }));
};

ws.onmessage = (event) => {
  const message = JSON.parse(event.data);
  console.log('Received:', message);

  switch (message.type) {
    case 'connected':
      console.log(
        `My ID: ${message.userId}, ${message.userCount} users online`,
      );
      break;
    case 'chat':
      displayChatMessage(message);
      break;
    case 'user_joined':
      console.log(`User ${message.userId} joined`);
      break;
  }
};

ws.onclose = () => {
  console.log('Disconnected');
  // Reconnect after a delay
  setTimeout(() => connectWebSocket(), 3000);
};

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

Handling Reconnection

Network connections are unreliable. Your client needs reconnection logic:

class ReconnectingWebSocket {
  constructor(url) {
    this.url = url;
    this.reconnectAttempts = 0;
    this.maxReconnectAttempts = 10;
    this.connect();
  }

  connect() {
    this.ws = new WebSocket(this.url);

    this.ws.onopen = () => {
      this.reconnectAttempts = 0;
      console.log('Connected');
    };

    this.ws.onclose = () => {
      if (this.reconnectAttempts < this.maxReconnectAttempts) {
        const delay = Math.min(
          1000 * Math.pow(2, this.reconnectAttempts),
          30000,
        );
        this.reconnectAttempts++;
        setTimeout(() => this.connect(), delay);
      }
    };

    this.ws.onmessage = (event) => {
      if (this.onMessage) this.onMessage(JSON.parse(event.data));
    };
  }

  send(data) {
    if (this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(data));
    }
  }
}

Scaling WebSockets

A single server can handle thousands of WebSocket connections, but for real production workloads, you’ll need multiple servers. The challenge: a client connected to Server A sends a message that needs to reach a client connected to Server B.

Solution: Redis Pub/Sub

Each server instance subscribes to a Redis channel. When a server receives a message, it publishes it to Redis. All servers receive it and forward it to their connected clients:

const redis = require('redis');
const subscriber = redis.createClient();
const publisher = redis.createClient();

subscriber.subscribe('ws:broadcast');

subscriber.on('message', (channel, message) => {
  // Forward to local clients
  const data = JSON.stringify(JSON.parse(message));
  for (const client of localClients.values()) {
    if (client.readyState === WebSocket.OPEN) {
      client.send(data);
    }
  }
});

// When receiving from a client
ws.on('message', (data) => {
  publisher.publish('ws:broadcast', data);
});

Message Ordering

With pub/sub across multiple servers, message ordering can be tricky. Strategies:

  1. Include a server timestamp and sequence number in each message
  2. Use a single writer pattern — one server handles all writes, others proxy
  3. Accept eventual ordering and handle it on the client side

Heartbeats and Ping/Pong

WebSocket connections can appear open but actually be dead (half-open connections). Implement heartbeats:

// Server-side heartbeat
const interval = setInterval(() => {
  server.clients.forEach((ws) => {
    if (!ws.isAlive) {
      return ws.terminate();
    }
    ws.isAlive = false;
    ws.ping();
  });
}, 30000);

server.on('connection', (ws) => {
  ws.isAlive = true;
  ws.on('pong', () => {
    ws.isAlive = true;
  });
});

Security Considerations

  1. Authenticate on upgrade: Validate the session token during the HTTP upgrade handshake
  2. Validate messages: Never trust client-sent data — validate type, structure, and content
  3. Rate limit: Prevent clients from flooding the server with messages
  4. Use wss://: Always use TLS in production (WebSocket Secure)
  5. Origin checking: Verify the Origin header during the handshake to prevent cross-site WebSocket hijacking
server.on('connection', (ws, req) => {
  const origin = req.headers.origin;
  if (origin !== 'https://yourdomain.com') {
    ws.close(4001, 'Unauthorized origin');
    return;
  }
});

When to Use WebSockets

  • Chat and messaging: Natural fit — bidirectional, low latency
  • Collaborative editing: Multiple users editing simultaneously
  • Live dashboards: Real-time metrics and monitoring
  • Gaming: Multi-player game state synchronization
  • Live notifications: Push updates to clients without polling

When Not to Use WebSockets

  • Occasional updates: If you only need to push updates every few minutes, Server-Sent Events (SSE) or polling is simpler
  • Request-response patterns: If the client always initiates, stick with HTTP
  • Unidirectional data: If the server only pushes to clients, SSE is a lighter-weight option

WebSockets are powerful but come with real complexity — reconnection, scaling, ordering, and operational overhead. Use them when you genuinely need bidirectional, low-latency communication. For simpler needs, simpler solutions exist.