🔥 0
0
Lesson 4 of 5 20 min +250 XP

Scaling WebSockets with Redis Pub/Sub

One WebSocket server can handle thousands of connections. But what happens when you need millions? Or when one server isn't reliable enough?

You need to scale horizontally - and that introduces a fundamental challenge.

The Scaling Challenge

The Problem

Server 1: Alice, Bob, Charlie connected
Server 2: David, Eve, Frank connected
Alice sends message to chat room...
Server 1 broadcasts to Bob, Charlie
David, Eve, Frank never see it!

Each server only knows about its own connections. They don't talk to each other.

Enter Redis Pub/Sub

Redis pub/sub creates a message bus between servers:

Server 1
Alice, Bob
Server 2
Charlie, David
↓ publish ↑ subscribe
Redis Pub/Sub
Channel: chat:general
Flow:
  • Alice (Server 1) sends a message
  • Server 1 publishes to Redis channel chat:general
  • All servers subscribed to chat:general receive it
  • Each server broadcasts to its connected clients
  • Everyone sees the message!

Implementation

npm install ws ioredis

Scalable WebSocket Server

// scalable-server.js
const WebSocket = require('ws');
const Redis = require('ioredis');

// Two Redis connections: one for pub, one for sub
// (Redis requires separate connections for subscribing)
const publisher = new Redis(process.env.REDIS_URL);
const subscriber = new Redis(process.env.REDIS_URL);

const wss = new WebSocket.Server({ port: process.env.PORT || 8080 });
const serverId = process.env.SERVER_ID || Math.random().toString(36).slice(2);

console.log(`Server ${serverId} starting...`);

// Track which rooms each client is in
const clientRooms = new Map(); // ws -> Set<room>

// Subscribe to Redis channels
subscriber.on('message', (channel, message) => {
  // channel format: "room:general"
  const room = channel.replace('room:', '');
  const data = JSON.parse(message);

  // Don't re-broadcast our own messages
  if (data.serverId === serverId) return;

  // Broadcast to all local clients in this room
  broadcastToLocalRoom(room, data.payload);
});

function broadcastToLocalRoom(room, payload) {
  const message = JSON.stringify(payload);

  wss.clients.forEach((client) => {
    const rooms = clientRooms.get(client);
    if (rooms?.has(room) && client.readyState === WebSocket.OPEN) {
      client.send(message);
    }
  });
}

function publishToRoom(room, payload) {
  // Publish to Redis so all servers receive it
  publisher.publish(`room:${room}`, JSON.stringify({
    serverId,
    payload
  }));

  // Also broadcast to local clients immediately
  broadcastToLocalRoom(room, payload);
}

wss.on('connection', (ws) => {
  clientRooms.set(ws, new Set());

  ws.on('message', (rawData) => {
    const { type, payload } = JSON.parse(rawData);

    switch (type) {
      case 'join':
        joinRoom(ws, payload.room);
        break;

      case 'leave':
        leaveRoom(ws, payload.room);
        break;

      case 'message':
        const rooms = clientRooms.get(ws);
        if (rooms?.has(payload.room)) {
          publishToRoom(payload.room, {
            type: 'message',
            room: payload.room,
            text: payload.text,
            userId: ws.userId,
            timestamp: Date.now()
          });
        }
        break;
    }
  });

  ws.on('close', () => {
    const rooms = clientRooms.get(ws);
    rooms?.forEach((room) => leaveRoom(ws, room));
    clientRooms.delete(ws);
  });
});

async function joinRoom(ws, room) {
  const rooms = clientRooms.get(ws);
  rooms.add(room);

  // Subscribe to Redis channel if first local client
  const localCount = countLocalRoomMembers(room);
  if (localCount === 1) {
    await subscriber.subscribe(`room:${room}`);
    console.log(`Subscribed to room:${room}`);
  }

  ws.send(JSON.stringify({
    type: 'joined',
    room
  }));

  publishToRoom(room, {
    type: 'user_joined',
    room,
    userId: ws.userId
  });
}

async function leaveRoom(ws, room) {
  const rooms = clientRooms.get(ws);
  rooms?.delete(room);

  publishToRoom(room, {
    type: 'user_left',
    room,
    userId: ws.userId
  });

  // Unsubscribe from Redis if no local clients
  const localCount = countLocalRoomMembers(room);
  if (localCount === 0) {
    await subscriber.unsubscribe(`room:${room}`);
    console.log(`Unsubscribed from room:${room}`);
  }
}

function countLocalRoomMembers(room) {
  let count = 0;
  wss.clients.forEach((client) => {
    if (clientRooms.get(client)?.has(room)) count++;
  });
  return count;
}

console.log(`Server ${serverId} running on port ${process.env.PORT || 8080}`);

Architecture Diagram

                        ┌──────────────────┐
                        │   Load Balancer   │
                        │  (sticky sessions)│
                        └────────┬─────────┘
                                 │
            ┌────────────────────┼────────────────────┐
            │                    │                    │
            ▼                    ▼                    ▼
    ┌───────────────┐   ┌───────────────┐   ┌───────────────┐
    │   Server 1    │   │   Server 2    │   │   Server 3    │
    │  WebSocket    │   │  WebSocket    │   │  WebSocket    │
    │  + Redis Sub  │   │  + Redis Sub  │   │  + Redis Sub  │
    └───────┬───────┘   └───────┬───────┘   └───────┬───────┘
            │                    │                    │
            └────────────────────┼────────────────────┘
                                 │
                        ┌────────▼────────┐
                        │     Redis       │
                        │    Pub/Sub      │
                        │   (Cluster)     │
                        └─────────────────┘

Sticky Sessions

WebSocket connections are stateful - the client must stay connected to the same server. Configure your load balancer for sticky sessions:

Nginx Configuration

upstream websocket_servers {
    ip_hash;  # Sticky sessions based on client IP
    server server1:8080;
    server server2:8080;
    server server3:8080;
}

server {
    listen 80;

    location /ws {
        proxy_pass http://websocket_servers;
        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;

        # Timeouts for long-lived connections
        proxy_read_timeout 86400s;
        proxy_send_timeout 86400s;
    }
}

AWS ALB

# AWS Application Load Balancer with sticky sessions
TargetGroup:
  Type: AWS::ElasticLoadBalancingV2::TargetGroup
  Properties:
    TargetGroupAttributes:
      - Key: stickiness.enabled
        Value: "true"
      - Key: stickiness.type
        Value: lb_cookie
      - Key: stickiness.lb_cookie.duration_seconds
        Value: "86400"

Presence Tracking

Track who's online across all servers using Redis:

const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);

async function setUserOnline(userId, serverId) {
  // Store which server the user is on
  await redis.hset('online_users', userId, serverId);
  // Set expiry (in case server crashes)
  await redis.expire('online_users', 120);
}

async function setUserOffline(userId) {
  await redis.hdel('online_users', userId);
}

async function getOnlineUsers() {
  return redis.hgetall('online_users');
}

async function isUserOnline(userId) {
  return redis.hexists('online_users', userId);
}

// Refresh presence periodically
setInterval(async () => {
  wss.clients.forEach((client) => {
    if (client.userId) {
      setUserOnline(client.userId, serverId);
    }
  });
}, 30000);

Scaling Considerations

Connection Limits

Each server handles ~10K-100K connections depending on message volume. Scale horizontally by adding servers.

Redis Throughput

Single Redis handles ~100K+ messages/sec. Use Redis Cluster for higher throughput.

Message Size

Keep messages small (<1KB). For large data, send a reference and let clients fetch via HTTP.

Graceful Shutdown

Drain connections before shutdown. Let clients reconnect to other servers.

Production Checklist

  • Load balancer with sticky sessions - Nginx ip_hash or ALB cookies
  • Redis for pub/sub - Separate connections for pub and sub
  • Heartbeats - Detect and clean up dead connections
  • Reconnection logic - Client-side exponential backoff
  • Monitoring - Track connections, messages/sec, latency
  • Graceful shutdown - Close connections cleanly on deploy

Alternative: Socket.IO with Redis Adapter

Socket.IO handles much of this automatically:

const { Server } = require('socket.io');
const { createAdapter } = require('@socket.io/redis-adapter');
const { createClient } = require('redis');

const io = new Server(3000);

const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();

Promise.all([pubClient.connect(), subClient.connect()]).then(() => {
  io.adapter(createAdapter(pubClient, subClient));
});

io.on('connection', (socket) => {
  socket.on('join', (room) => {
    socket.join(room);
  });

  socket.on('message', (room, text) => {
    // Automatically broadcasts to all servers!
    io.to(room).emit('message', { text, from: socket.id });
  });
});
Socket.IO Trade-offs

Socket.IO adds ~30KB to client bundle, has its own protocol (not pure WebSocket), but handles reconnection, rooms, and broadcasting automatically.

Key Takeaways

  • Single server limitation - Servers don't know about each other's clients
  • Redis pub/sub - Message bus between servers
  • Sticky sessions required - Client must stay on same server
  • Two Redis connections - Separate for publish and subscribe
  • Consider Socket.IO - Handles scaling complexity for you

Next up: WebSocket Assessment - Test your understanding of WebSocket fundamentals.

🧠 Quick Quiz

Test your understanding of this lesson.

1

Why can't you simply add more WebSocket servers behind a load balancer?

2

What is Redis pub/sub used for in WebSocket scaling?

3

What is 'sticky sessions' in the context of WebSocket?

Client & Server Implementation