Backend
WebSocket
Scaling with Redis Pub/Sub

Scaling WebSockets with Redis Pub/Sub

A single Node.js WebSocket process can handle thousands of concurrent connections. But what happens when you scale to hundreds of thousands of users, or when you deploy across multiple server instances behind a load balancer?

Scaling WebSockets horizontally introduces a fundamental distributed systems challenge: connection isolation.


1. The Multi-Server Scaling Problem

The Problem:

If Alice (connected to Server 1) sends a message to #general, Server 1 broadcasts it to Bob. However, Charlie and David (connected to Server 2) never receive the message because Server 1 has no memory or knowledge of sockets connected to Server 2!


2. The Solution: Redis Pub/Sub as a Message Bus

Redis Pub/Sub (Publish/Subscribe) acts as a high-speed, cross-server message bus connecting all your WebSocket backend instances.

Flow of Execution:

  1. Alice sends a message to Server 1.
  2. Server 1 publishes the message to the Redis channel room:general.
  3. All servers subscribed to room:general receive the event simultaneously from Redis.
  4. Each server broadcasts the message to its own locally connected sockets.
  5. Everyone in the room receives the message seamlessly, regardless of which physical server they are connected to.

3. Scalable Node.js Server Implementation (scalableServer.js)

Install the required packages:

npm install ws ioredis
import { WebSocketServer, WebSocket } from "ws";
import Redis from "ioredis";
 
// In Redis, subscriber mode blocks normal commands.
// You MUST create two separate Redis client instances: one for publishing, one for subscribing.
const publisher = new Redis(process.env.REDIS_URL || "redis://localhost:6379");
const subscriber = new Redis(process.env.REDIS_URL || "redis://localhost:6379");
 
const serverId = process.env.SERVER_ID || `srv_${Math.random().toString(36).slice(2, 8)}`;
const PORT = process.env.PORT || 8080;
 
const wss = new WebSocketServer({ port: PORT });
console.log(`⚡ WebSocket instance ${serverId} running on port ${PORT}`);
 
// Map: ws -> Set<roomName>
const clientRooms = new Map();
 
// 1. Listen for cross-server messages forwarded by Redis
subscriber.on("message", (channel, messageString) => {
  const roomName = channel.replace("room:", "");
  const { originServerId, payload } = JSON.parse(messageString);
 
  // If this server published the message, we already delivered it locally
  if (originServerId === serverId) return;
 
  // Broadcast to all sockets connected to this local instance
  broadcastToLocalRoom(roomName, payload);
});
 
// Helper: Broadcast to local sockets in a specific room
function broadcastToLocalRoom(roomName, payload) {
  const data = JSON.stringify(payload);
  wss.clients.forEach((client) => {
    const userRooms = clientRooms.get(client);
    if (userRooms?.has(roomName) && client.readyState === WebSocket.OPEN) {
      client.send(data);
    }
  });
}
 
// Helper: Publish message to Redis
function publishToRoom(roomName, payload) {
  // 1. Send to Redis message bus for other server instances
  publisher.publish(
    `room:${roomName}`,
    JSON.stringify({ originServerId: serverId, payload })
  );
 
  // 2. Deliver to local clients immediately
  broadcastToLocalRoom(roomName, payload);
}
 
// 2. Handle Client WebSocket Connections
wss.on("connection", (ws) => {
  ws.userId = `usr_${Math.random().toString(36).substring(7)}`;
  clientRooms.set(ws, new Set());
 
  ws.on("message", async (raw) => {
    try {
      const { type, room, text } = JSON.parse(raw);
 
      if (type === "join") {
        const rooms = clientRooms.get(ws);
        rooms.add(room);
 
        // If this is the FIRST local client entering this room on this server,
        // subscribe this server instance to the Redis channel
        const count = countLocalClientsInRoom(room);
        if (count === 1) {
          await subscriber.subscribe(`room:${room}`);
          console.log(`📡 [${serverId}] Subscribed to Redis channel: room:${room}`);
        }
 
        publishToRoom(room, {
          type: "notification",
          text: `User ${ws.userId} joined #${room}`
        });
      }
 
      if (type === "chat") {
        publishToRoom(room, {
          type: "chat",
          from: ws.userId,
          room,
          text,
          timestamp: Date.now()
        });
      }
    } catch (err) {
      console.error("Message processing error:", err);
    }
  });
 
  ws.on("close", async () => {
    const rooms = clientRooms.get(ws);
    if (rooms) {
      for (const room of rooms) {
        rooms.delete(room);
        publishToRoom(room, {
          type: "notification",
          text: `User ${ws.userId} left #${room}`
        });
 
        // If no clients on this server remain in the room, unsubscribe from Redis
        if (countLocalClientsInRoom(room) === 0) {
          await subscriber.unsubscribe(`room:${room}`);
          console.log(`🔕 [${serverId}] Unsubscribed from Redis channel: room:${room}`);
        }
      }
    }
    clientRooms.delete(ws);
  });
});
 
function countLocalClientsInRoom(roomName) {
  let count = 0;
  wss.clients.forEach((c) => {
    if (clientRooms.get(c)?.has(roomName)) count++;
  });
  return count;
}

4. Load Balancing & Sticky Sessions

Because WebSockets are stateful, your load balancer must direct the initial HTTP upgrade request and keep subsequent traffic pinned to the same backend server instance (Sticky Sessions).

Nginx Reverse Proxy Configuration

upstream websocket_cluster {
    # ip_hash ensures client IP stays sticky to the same server node
    ip_hash;
    server 10.0.0.1:8080;
    server 10.0.0.2:8080;
    server 10.0.0.3:8080;
}
 
server {
    listen 80;
    server_name ws.example.com;
 
    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;
 
        # Keep persistent connections open without timing out
        proxy_read_timeout 86400s;
        proxy_send_timeout 86400s;
    }
}

5. Global Presence Tracking with Redis

To display a list of online users across all distributed nodes:

import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
 
// Mark user as active and record server location
export async function setUserOnline(userId, serverId) {
  await redis.hset("online_users", userId, serverId);
}
 
// Remove user on disconnect
export async function setUserOffline(userId) {
  await redis.hdel("online_users", userId);
}
 
// Get global list of all online users
export async function getOnlineUsers() {
  return await redis.hgetall("online_users");
}
 
// Check if specific user is currently online
export async function isUserOnline(userId) {
  return (await redis.hexists("online_users", userId)) === 1;
}

6. Socket.IO Alternative: Automatic Redis Adapter

If you want automatic multi-server scaling without writing custom Redis Pub/Sub bridging, Socket.IO provides a first-party @socket.io/redis-adapter:

import { Server } from "socket.io";
import { createAdapter } from "@socket.io/redis-adapter";
import { createClient } from "redis";
 
const io = new Server(3000);
 
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
 
await Promise.all([pubClient.connect(), subClient.connect()]);
 
// Plug in the Redis adapter
io.adapter(createAdapter(pubClient, subClient));
 
io.on("connection", (socket) => {
  socket.on("join_room", (room) => socket.join(room));
 
  socket.on("chat_message", ({ room, text }) => {
    // Automatically routes across all cluster servers through Redis!
    io.to(room).emit("chat_message", { text, from: socket.id });
  });
});

Trade-Offs: Raw ws vs. Socket.IO

FeatureRaw ws + Custom RedisSocket.IO + Redis Adapter
Client Bundle Size0 KB (Native browser WebSocket API)~30 KB client JS library
ProtocolStandard RFC 6455 WebSocketCustom Engine.IO framing protocol
Reconnection & FallbackManual custom implementationAutomatic HTTP long-polling fallback & reconnects
Rooms & MulticastingCustom Redis Pub/Sub logicBuilt-in native primitives (socket.join, io.to)

đź’ˇ Production Scaling Checklist

  • Sticky Sessions Configured: Verified with Nginx ip_hash or AWS ALB cookie stickiness.
  • Dual Redis Connections: Separate instances for publisher and subscriber.
  • Small Payload Size: Keep message frames small (under 2KB); send URLs for images/videos.
  • Liveness Heartbeats: Clean up dead sockets every 30 seconds with ping/pong frames.
  • Graceful Shutdown: Intercept SIGTERM signals to notify clients to reconnect before terminating containers.