WebSockets provide low-latency, bidirectional communication essential for real-time dashboards, live financial tickers, chat platforms, and collaborative editing tools. While running a single WebSocket server in Node.js is simple, scaling to tens of thousands of concurrent connections introduces a core architectural challenge: the memory boundary of a single Node.js process.

When your application scales horizontally across multiple container instances or servers behind a load balancer, clients connected to Server A cannot receive messages broadcast by clients connected to Server B.

In this deep systems guide, we construct a scalable, distributed WebSocket architecture using Node.js, the ultra-fast ws library, and Redis Pub/Sub as an inter-server message bus.


1. The Multi-Server WebSocket Scaling Dilemma

HTTP requests are stateless; any server in a cluster can handle any request independently. WebSockets, however, are persistent TCP connections:

[User Alice] ──(WebSocket)──> [Node Instance 1 (Port 8001)]
                                        ❌ NO DIRECT LINK
[User Bob]   ──(WebSocket)──> [Node Instance 2 (Port 8002)]

If Alice publishes a chat message, Instance 1 has no reference to Bob's socket in memory. To solve this without tight coupling between instances, we introduce Redis Pub/Sub as a high-speed, in-memory distribution backplane:

[User Alice] ──> [Node 1] ──(Publish)──> [Redis Cluster]
                                                β”‚
                                                └──(Broadcast)──> [Node 2] ──> [User Bob]

2. Production Node.js WebSocket Server with Redis

Below is the complete implementation using the lightweight ws package and the official @redis/client driver with dual connection channels (publisher and subscriber).

// server/websocket-node.ts
import http from 'http';
import { WebSocketServer, WebSocket } from 'ws';
import { createClient } from 'redis';

const PORT = parseInt(process.env.PORT || '8080', 10);
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
const CHANNEL_NAME = 'global-realtime-events';

// 1. Establish Dedicated Redis Connections
// (Redis requires separate clients for publishing and subscribing)
const pubClient = createClient({ url: REDIS_URL });
const subClient = pubClient.duplicate();

async function startServer() {
  await pubClient.connect();
  await subClient.connect();
  console.log('βœ… Connected to Redis Pub/Sub backplane.');

  const server = http.createServer((req, res) => {
    if (req.url === '/healthz') {
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify({ status: 'healthy', clients: wss.clients.size }));
      return;
    }
    res.writeHead(404);
    res.end();
  });

  const wss = new WebSocketServer({ server });

  // 2. Listen for messages from Redis and broadcast to local connected sockets
  await subClient.subscribe(CHANNEL_NAME, (messageString) => {
    try {
      const payload = JSON.parse(messageString);
      
      // Broadcast to all active clients on this specific Node instance
      wss.clients.forEach((client) => {
        if (client.readyState === WebSocket.OPEN) {
          // Avoid echoing back to the original sender if sender ID matches
          client.send(JSON.stringify(payload));
        }
      });
    } catch (err) {
      console.error('Error parsing Redis broadcast:', err);
    }
  });

  // 3. Handle Client Connections & Heartbeats
  wss.on('connection', (ws: WebSocket, req) => {
    (ws as any).isAlive = true;
    (ws as any).clientId = Math.random().toString(36).substring(2, 9);

    ws.on('pong', () => {
      (ws as any).isAlive = true;
    });

    ws.on('message', async (data: string) => {
      try {
        const parsed = JSON.parse(data.toString());
        const eventMessage = {
          sender: (ws as any).clientId,
          timestamp: Date.now(),
          event: parsed.event,
          data: parsed.data
        };

        // Publish to Redis instead of local broadcast
        await pubClient.publish(CHANNEL_NAME, JSON.stringify(eventMessage));
      } catch (err) {
        ws.send(JSON.stringify({ error: 'Malformed JSON payload' }));
      }
    });

    ws.on('close', () => {
      // Cleanup client hooks
    });
  });

  // 4. Dead Connection Reaper (Heartbeat Ping/Pong)
  // Prunes dead sockets (mobile clients losing signal, laptop lid closed)
  const heartbeatInterval = setInterval(() => {
    wss.clients.forEach((ws: any) => {
      if (!ws.isAlive) {
        console.log(`Reaping dead socket: ${ws.clientId}`);
        return ws.terminate();
      }
      ws.isAlive = false;
      ws.ping();
    });
  }, 30000);

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

  server.listen(PORT, () => {
    console.log(`πŸš€ WebSocket server running on port ${PORT}`);
  });
}

startServer().catch(console.error);

3. Load Balancing with Nginx and Sticky Sessions

When deploying behind Nginx or an AWS Application Load Balancer (ALB), ensure WebSocket upgrade headers are passed correctly and sticky sessions are configured:

# /etc/nginx/conf.d/websocket.conf
upstream websocket_cluster {
    # Hash on client IP for session affinity
    ip_hash;
    server 10.0.1.10:8080;
    server 10.0.1.11:8080;
    server 10.0.1.12:8080;
}

server {
    listen 80;
    server_name realtime.zebmalik.tech;

    location /ws {
        proxy_pass http://websocket_cluster;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        
        # Generous timeouts for long-lived connections
        proxy_read_timeout 86400s;
        proxy_send_timeout 86400s;
    }
}

4. Memory Optimization: Scaling to 100,000 Connections

In Node.js, each active WebSocket connection consumes memory for TCP buffers and V8 object references. Without tuning, a 2GB RAM container crashes at ~15,000 connections.

Key Optimization Rules

  1. Disable permessage-deflate: WebSocket compression adds substantial CPU and memory overhead per socket:
    const wss = new WebSocketServer({ server, perMessageDeflate: false });
    
  2. Tune Linux File Descriptors: By default, Linux limits open sockets to 1,024 per process. Increase limits in /etc/security/limits.conf:
    * soft nofile 200000
    * hard nofile 200000
    
  3. TCP Buffer Tuning: Adjust kernel read/write buffers in /etc/sysctl.conf:
    net.ipv4.tcp_rmem = 4096 87380 4194304
    net.ipv4.tcp_wmem = 4096 65536 4194304
    

With these settings applied, memory consumption drops from 65KB per socket to ~12KB per socket, allowing a single 4GB container to easily sustain 50,000 active connections.


Frequently Asked Questions (FAQ)

What is the latency overhead of routing messages through Redis Pub/Sub?

In local networks and cloud VPCs, Redis Pub/Sub introduces less than 0.8 milliseconds of delivery latency. For 99% of real-time applications (chat, notifications, analytics dashboards), this overhead is completely imperceptible to human users.

What happens if Redis goes down?

If the central Redis instance becomes unavailable, existing client connections remain active, but inter-server cross-broadcast stops. To ensure high availability, deploy Redis in Sentinel or Cluster mode with automatic leader failover.

Should I use Socket.io or the raw ws package?

Socket.io provides useful fallbacks (like HTTP long-polling) and automatic reconnection logic, but has higher memory and CPU overhead. For high-throughput systems scaling beyond 20,000 concurrent sockets, raw ws is significantly faster, more predictable, and consumes 60% less RAM.