# 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.

- **Published:** 2025-04-13
- **Author:** Clixo
- **Reading time:** 6 min read
- **Tags:** websockets, redis, pub-sub, scaling, nodejs, architecture
- **Canonical URL:** https://clixo.sh/blog/scaling-websockets-redis-pubsub-horizontal

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 ◄─┘
```

```mermaid
sequenceDiagram
  participant CA as Client A
  participant I1 as Instance 1
  participant R as Redis Pub/Sub
  participant I2 as Instance 2
  participant CB as Client B

  CA->>I1: broadcast to room:789
  I1->>R: PUBLISH room:789 message
  R-->>I1: deliver to local subscribers
  R-->>I2: deliver to local subscribers
  I1->>CA: send to local room members
  I2->>CB: send to local room members
```

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.

```js
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:

```js
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:

```js
// 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.

```js
// 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](https://clixo.sh/#contact).

---

Clixo · 1141 W Bryn Mawr Ave, Itasca, IL 60143, US · [hello@clixo.sh](mailto:hello@clixo.sh)
[Start a build](https://clixo.sh/#contact) · [All services](https://clixo.sh/services) · [Agent guide (llms.txt)](https://clixo.sh/llms.txt)
