WritingScaling WebSockets Horizontally with Redis Pub/Sub: A Production Architecture Guide — Clixo
6 min readwebsockets, redis, pub-sub, scaling, nodejs, architecture

Scaling WebSockets Horizontally with Redis Pub/Sub: A Production Architecture Guide

Learn how to scale WebSocket servers horizontally using Redis Pub/Sub to synchronize messages and presence state across multiple Node.js server instances.

You have a WebSocket server that works perfectly on a single instance. Now you need to run two, five, or twenty instances behind a load balancer. Immediately, your broadcasts break — a message published by a client on instance A never reaches clients connected to instance B. This is the canonical scaling problem for stateful socket servers, and Redis Pub/Sub is the most direct solution.

Why a Single Message Broker Fixes the Multi-Instance Problem

Each server instance holds its own in-memory connection map. Instance A knows about its own 5,000 connections. Instance B knows about its own 5,000. Neither can directly write to the other's sockets.

A shared message broker — Redis Pub/Sub in this pattern — acts as an event bus. When instance A needs to broadcast a message to room doc:789, it publishes to a Redis channel. Every instance subscribed to that channel (including A itself) receives the message and delivers it to their locally connected clients who are in that room.

The result: any server can send a message that reaches any client, regardless of which instance the client is physically connected to.

Architecture Overview

Client A ─── WebSocket ──► Instance 1 ──► Redis Publish
Client B ─── WebSocket ──► Instance 2 ─────────────────────┐
Client C ─── WebSocket ──► Instance 1 ◄── Redis Subscribe ◄─┘

Instances both publish and subscribe. A publish from instance 1 is received by all instances (including instance 1 itself if it has a self-subscription, or you can filter your own publishes).

Setting Up the Pattern in Node.js

You need two Redis clients: one dedicated to publishing and one to subscribing. A single client cannot be used for both because a subscribing client enters a mode where it can only execute pub/sub commands.

const Redis = require('ioredis');
const { WebSocketServer } = require('ws');
 
const publisher  = new Redis({ host: 'redis', port: 6379 });
const subscriber = new Redis({ host: 'redis', port: 6379 });
 
const wss = new WebSocketServer({ port: 3000 });
const rooms = new Map(); // roomId -> Set of local sockets
 
wss.on('connection', (socket) => {
  socket.on('message', async (raw) => {
    const msg = JSON.parse(raw);
 
    if (msg.type === 'join') {
      if (!rooms.has(msg.roomId)) rooms.set(msg.roomId, new Set());
      rooms.get(msg.roomId).add(socket);
      socket.roomId = msg.roomId;
    }
 
    if (msg.type === 'broadcast') {
      await publisher.publish(
        `room:${socket.roomId}`,
        JSON.stringify({ senderId: socket.userId, data: msg.data })
      );
    }
  });
 
  socket.on('close', () => {
    const room = rooms.get(socket.roomId);
    if (room) room.delete(socket);
  });
});

Subscribe to room channels when clients join them, or use a wildcard subscription pattern:

subscriber.psubscribe('room:*');
 
subscriber.on('pmessage', (pattern, channel, raw) => {
  const roomId = channel.replace('room:', '');
  const localClients = rooms.get(roomId);
  if (!localClients) return;
 
  localClients.forEach((client) => {
    if (client.readyState === 1) { // OPEN
      client.send(raw);
    }
  });
});

Pattern subscriptions (psubscribe) match all channels fitting the pattern. This means each instance receives events for all rooms, but only delivers to clients it actually has locally. Instances with no clients in a room receive the event but do nothing with it — a small overhead that is generally acceptable.

Scaling WebSocket Presence with Redis Pub/Sub

Presence — tracking who is online across all instances — requires shared state, not just event delivery. Use Redis hashes to maintain a global connection count per user:

// On connect (after auth)
await publisher.hincrby('presence', userId, 1);
await publisher.publish('presence:events', JSON.stringify({ userId, status: 'online' }));
 
// On disconnect
const remaining = await publisher.hincrby('presence', userId, -1);
if (remaining <= 0) {
  await publisher.hdel('presence', userId);
  await publisher.publish('presence:events', JSON.stringify({ userId, status: 'offline' }));
}

Any instance can query HGETALL presence to get the current online user list. The Pub/Sub channel delivers real-time presence changes to all instances so they can push updates to locally connected clients.

Handling Crashed Instances

If an instance crashes without cleanly decrementing its presence counters, the Redis hash will have stale entries. Mitigate this by:

  1. Setting a TTL on presence keys (heartbeat-refreshed every 30 seconds)
  2. Running a periodic reconciliation job that compares Redis presence counts against actual WebSocket connection counts across all instances

Redis Streams as an Alternative

Redis Pub/Sub is fire-and-forget. If no instance is subscribed at the moment a message is published, the message is lost. For most WebSocket use cases (where the client is live and connected), this is acceptable — the message was for a currently connected client.

If you need durability — for example, to replay missed messages when a client reconnects — use Redis Streams instead. Streams are persistent, ordered, and support consumer groups. Each instance reads from the stream as a consumer group member, ensuring each message is processed by exactly one instance.

// Publish to stream
await publisher.xadd('room:789:stream', '*', 'payload', JSON.stringify(msg));
 
// Read from stream (each instance reads its own entries)
const messages = await subscriber.xreadgroup(
  'GROUP', instanceGroupName, instanceId,
  'COUNT', 100, 'BLOCK', 0,
  'STREAMS', 'room:789:stream', '>'
);

Streams add operational complexity but give you the message history needed for reconnection replay.

When to Move Beyond Redis Pub/Sub

Redis Pub/Sub is the right solution for teams scaling from one to several dozen instances with message rates in the hundreds of thousands per second range. When you exceed that, consider:

  • NATS — lower latency than Redis, built for high-throughput messaging, supports at-least-once and exactly-once delivery semantics
  • Kafka — for extremely high volumes where durability, replay, and partitioned consumer groups are first-class requirements
  • Managed services — Ably, Pusher, and similar platforms handle the broker infrastructure entirely

The Redis Pub/Sub pattern gets most products through their first meaningful scale milestone. Start there, measure, and migrate only when you have evidence you need to.

Building scalable real-time infrastructure requires getting the architecture right before you are under load. If you are approaching that scale inflection point, Clixo can design and ship the system with you.