WritingScaling WebSockets Horizontally with Redis Pub/Sub: A Production Architecture Guide — LinkIT
5 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.

Each WebSocket server instance can write only to its own connections. When clients connected to different instances need the same broadcast, a shared broker such as Redis Pub/Sub can distribute the message between servers.

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

Measure broker throughput, fan-out, and delivery requirements with your own workload. Alternatives to evaluate when the current design no longer fits include:

  • 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

Load-test the broker and reconnect behavior before expanding the deployment. Change the design when measurements or delivery requirements justify it.

Discuss WebSocket scaling and message delivery with LinkIT.