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
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:
- Alice (Server 1) sends a message
- Server 1 publishes to Redis channel
chat:general - All servers subscribed to
chat:generalreceive 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 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.