WebSockets: Real-Time Bidirectional Communication

WebSockets solve a fundamental limitation of HTTP: the request-response model. Traditional HTTP requires the client to initiate every interaction. For real-time applications, this means resorting to...

Key Insights

  • WebSockets provide true bidirectional communication over a single TCP connection, eliminating the overhead of repeated HTTP requests and enabling sub-50ms message latency for real-time applications.
  • The protocol begins with an HTTP upgrade handshake, then maintains a persistent connection where both client and server can send messages independently without request-response constraints.
  • Production WebSocket implementations require heartbeat mechanisms, exponential backoff reconnection strategies, and proper state management to handle network instability and connection lifecycle events.

Introduction to WebSockets

WebSockets solve a fundamental limitation of HTTP: the request-response model. Traditional HTTP requires the client to initiate every interaction. For real-time applications, this means resorting to inefficient workarounds like polling, where clients repeatedly ask “anything new?” every few seconds.

The WebSocket protocol establishes a persistent, full-duplex communication channel over a single TCP connection. After an initial HTTP handshake, the connection upgrades to WebSocket, allowing both client and server to send messages independently at any time. This eliminates polling overhead and reduces latency from seconds to milliseconds.

Use WebSockets when you need instant, bidirectional updates: chat applications, collaborative editing tools, live sports scores, multiplayer games, real-time dashboards, or financial trading platforms. If you only need server-to-client updates, consider Server-Sent Events (SSE) as a simpler alternative.

The WebSocket Handshake

The WebSocket connection begins as a standard HTTP request with special headers indicating an upgrade request. The server responds with a 101 Switching Protocols status, and the connection transforms into a WebSocket.

Here’s what the handshake looks like:

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

The server responds:

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

Client-side connection is straightforward:

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

ws.addEventListener('open', (event) => {
  console.log('Connected to WebSocket server');
  ws.send('Hello Server!');
});

ws.addEventListener('message', (event) => {
  console.log('Message from server:', event.data);
});

Use wss:// for secure connections in production, just as you’d use HTTPS instead of HTTP.

Building a Simple WebSocket Server

The ws library provides a robust WebSocket implementation for Node.js. Install it with npm install ws, then create a basic chat server:

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

const clients = new Set();

wss.on('connection', (ws) => {
  clients.add(ws);
  console.log('New client connected. Total clients:', clients.size);

  ws.on('message', (message) => {
    console.log('Received:', message.toString());
    
    // Broadcast to all connected clients
    clients.forEach((client) => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(message.toString());
      }
    });
  });

  ws.on('close', () => {
    clients.delete(ws);
    console.log('Client disconnected. Total clients:', clients.size);
  });

  ws.on('error', (error) => {
    console.error('WebSocket error:', error);
    clients.delete(ws);
  });
});

console.log('WebSocket server running on ws://localhost:8080');

This server maintains a set of active connections, broadcasts received messages to all clients, and cleans up disconnected clients. The readyState check prevents errors when sending to closing connections.

Client-Side Implementation

A production-ready WebSocket client needs more than basic connection handling. Implement reconnection logic to handle network interruptions:

class WebSocketClient {
  constructor(url) {
    this.url = url;
    this.ws = null;
    this.reconnectAttempts = 0;
    this.maxReconnectAttempts = 5;
    this.reconnectDelay = 1000;
    this.messageQueue = [];
  }

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

    this.ws.addEventListener('open', () => {
      console.log('Connected');
      this.reconnectAttempts = 0;
      this.flushMessageQueue();
    });

    this.ws.addEventListener('message', (event) => {
      this.handleMessage(event.data);
    });

    this.ws.addEventListener('close', () => {
      console.log('Disconnected');
      this.reconnect();
    });

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

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

  flushMessageQueue() {
    while (this.messageQueue.length > 0) {
      const message = this.messageQueue.shift();
      this.send(message);
    }
  }

  reconnect() {
    if (this.reconnectAttempts >= this.maxReconnectAttempts) {
      console.error('Max reconnection attempts reached');
      return;
    }

    const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts);
    console.log(`Reconnecting in ${delay}ms...`);
    
    setTimeout(() => {
      this.reconnectAttempts++;
      this.connect();
    }, delay);
  }

  handleMessage(data) {
    const message = JSON.parse(data);
    console.log('Received:', message);
  }
}

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

This implementation queues messages when disconnected, implements exponential backoff for reconnection attempts, and provides clean separation of concerns.

Practical Use Case: Real-Time Notifications

Build a notification system where the server pushes updates to subscribed clients:

// Server-side
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

const subscribers = new Map();

wss.on('connection', (ws) => {
  ws.on('message', (message) => {
    const data = JSON.parse(message);
    
    if (data.type === 'subscribe') {
      subscribers.set(ws, data.userId);
      console.log(`User ${data.userId} subscribed`);
    }
  });

  ws.on('close', () => {
    subscribers.delete(ws);
  });
});

// Function to push notification to specific user
function notifyUser(userId, notification) {
  subscribers.forEach((subscribedUserId, client) => {
    if (subscribedUserId === userId && client.readyState === WebSocket.OPEN) {
      client.send(JSON.stringify({
        type: 'notification',
        data: notification,
        timestamp: Date.now()
      }));
    }
  });
}

// Simulate notification
setInterval(() => {
  notifyUser('user123', {
    title: 'New Message',
    body: 'You have a new message!'
  });
}, 10000);

Client-side notification handler:

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

ws.addEventListener('open', () => {
  ws.send(JSON.stringify({
    type: 'subscribe',
    userId: 'user123'
  }));
});

ws.addEventListener('message', (event) => {
  const message = JSON.parse(event.data);
  
  if (message.type === 'notification') {
    displayNotification(message.data);
  }
});

function displayNotification(notification) {
  const notificationEl = document.createElement('div');
  notificationEl.className = 'notification';
  notificationEl.innerHTML = `
    <h4>${notification.title}</h4>
    <p>${notification.body}</p>
  `;
  document.getElementById('notifications').appendChild(notificationEl);
}

Error Handling and Best Practices

Production WebSocket implementations need heartbeat mechanisms to detect dead connections:

// Server-side heartbeat
wss.on('connection', (ws) => {
  ws.isAlive = true;
  
  ws.on('pong', () => {
    ws.isAlive = true;
  });
});

const interval = setInterval(() => {
  wss.clients.forEach((ws) => {
    if (ws.isAlive === false) {
      return ws.terminate();
    }
    
    ws.isAlive = false;
    ws.ping();
  });
}, 30000);

wss.on('close', () => {
  clearInterval(interval);
});

Client-side heartbeat response:

ws.addEventListener('ping', () => {
  ws.pong();
});

Security considerations:

  • Always use WSS (WebSocket Secure) in production
  • Validate and sanitize all incoming messages
  • Implement authentication during the handshake using tokens or cookies
  • Set origin restrictions to prevent unauthorized connections
  • Rate limit message frequency to prevent abuse

For scaling beyond a single server, use Redis pub/sub or a message broker to synchronize messages across multiple WebSocket server instances.

WebSockets vs Alternatives

Server-Sent Events (SSE): Use for server-to-client updates only. Simpler implementation, automatic reconnection, works over HTTP/2. Choose SSE for live feeds, notifications, or stock tickers where clients don’t send data.

Long Polling: Legacy approach where clients make long-lived HTTP requests. Only use when WebSockets aren’t supported (rare in modern browsers). Higher latency and server overhead.

HTTP/2 Server Push: Good for pushing resources during page load, but not for application-level real-time data.

Choose WebSockets when you need bidirectional communication, sub-100ms latency, or high message frequency. The protocol overhead is minimal after the initial handshake, making it efficient for sustained real-time interaction.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.