Redis sharded pub/sub for WebSocket fan-out #

You moved WebSocket fan-out from a single Redis to a Redis Cluster to get more capacity — and throughput went down. Every node in the cluster now carries every published message, the cluster bus saturates, and adding more shards makes it worse. This is not a misconfiguration: classic Redis pub/sub in cluster mode broadcasts each PUBLISH to every node, because subscribers may be connected to any of them. Redis 7 introduced sharded pub/sub (SPUBLISH, SSUBSCRIBE), which routes each channel to the shard that owns its hash slot, like a key. For WebSocket fan-out, where the number of channels (rooms, documents, users) is large and each has few subscribing servers, it is the difference between a cluster that scales and one that does not.

Root cause #

In a Redis Cluster, keys are partitioned across 16,384 hash slots, and each shard owns a range of slots. Classic pub/sub predates clustering and has no notion of slots: a client may SUBSCRIBE on any node, so a PUBLISH must reach all of them. Redis Cluster achieves this by propagating every published message over the cluster bus to every node. Total pub/sub work therefore scales with nodes × messages, and adding shards adds cost rather than capacity.

Sharded pub/sub treats a channel name like a key. SSUBSCRIBE room:42 must be sent to the shard that owns the slot for room:42, and SPUBLISH room:42 goes to that same shard, which delivers it to its subscribers. No other node sees the message. Work per shard is proportional to the channels it owns, so adding shards adds capacity — which is what you expected clustering to do in the first place.

Messages each shard must process per second With classic pub/sub every shard processes every message, so per-shard load stays at the full twenty thousand plus cluster-bus traffic and total work grows with shard count; with sharded pub/sub each shard handles only its own channels. Messages each shard must process per second 20k publishes/s spread evenly over many channels classic PUBLISH sharded SPUBLISH 0k 100k 200k 300k 60k 3 shards 120k 6 shards 240k 12 shards
Classic pub/sub gets more expensive as you add shards; sharded pub/sub gets cheaper per shard.

Resolution #

Switch the WebSocket fan-out bridge from PUBLISH/SUBSCRIBE to SPUBLISH/SSUBSCRIBE, using a cluster-aware client that routes each channel to its owning shard. Keep the architecture from scaling WebSocket broadcast with Redis pub/sub — each WebSocket node subscribes to the channels its local clients need, reference-counted — and change only the commands and the client.

import { createCluster } from 'redis';
import type { WebSocket } from 'ws';

const pub = createCluster({ rootNodes: [{ url: 'redis://redis-0:6379' }, { url: 'redis://redis-1:6379' }] });
const sub = pub.duplicate(); // a dedicated connection set for subscriptions
await Promise.all([pub.connect(), sub.connect()]);

// Local room membership on THIS WebSocket node.
const localMembers = new Map<string, Set<WebSocket>>();

// Channel naming: one channel per room. A hash tag {…} would force channels into
// one slot — only use it deliberately (see edge cases).
const channelFor = (room: string) => `room:${room}`;

function deliverLocal(room: string, message: string) {
for (const ws of localMembers.get(room) ?? []) {
if (ws.readyState === ws.OPEN && ws.bufferedAmount < 1_000_000) ws.send(message);
}
}

export async function join(room: string, ws: WebSocket) {
let set = localMembers.get(room);
if (!set) {
localMembers.set(room, (set = new Set()));
// First local member: subscribe this node to the room's shard.
await sub.sSubscribe(channelFor(room), (message) => deliverLocal(room, message));
}
set.add(ws);
}

export async function leave(room: string, ws: WebSocket) {
const set = localMembers.get(room);
if (!set) return;
set.delete(ws);
if (set.size === 0) {
localMembers.delete(room);
await sub.sUnsubscribe(channelFor(room)); // last local member: stop receiving
}
}

export async function publish(room: string, payload: object) {
// Routed to the single shard that owns the channel's slot; only that shard fans out.
await pub.sPublish(channelFor(room), JSON.stringify(payload));
}

The node-level reference counting is what keeps Redis subscriptions proportional to rooms with local members rather than to connections: a node with 5,000 clients in 300 rooms holds 300 shard subscriptions, not 5,000. Combined with sharded routing, each published message is processed by one shard and delivered to only the WebSocket nodes that have members in that room.

Classic vs sharded publish in a 3-shard cluster A classic PUBLISH to shard A is copied over the cluster bus to shards B and C; a sharded SPUBLISH goes directly to shard B, which owns the channel's slot, and is delivered only to its sharded subscribers. Classic vs sharded publish in a 3-shard cluster WS node Shard A Shard B Shard C PUBLISH room:42 cluster bus copy cluster bus copy SPUBLISH room:42 (slot on B) deliver to SSUBSCRIBErs only Sharded publishes never touch shards that do not own the channel
Slots make pub/sub partitioned, the same way they partition keys.

Edge cases #

Hot rooms concentrate on one shard. A single very busy channel lives on one shard, so its traffic cannot be spread by adding shards. For a room that dominates load (a global announcement channel, a celebrity stream), split it deliberately: publish to room:42:0 through room:42:N by hashing the sender, and have WebSocket nodes subscribe to all partitions for that room.

Hash tags change placement. A channel name with a hash tag, such as {tenant:7}:room:42, places every channel of that tenant on one slot. That can be useful — a tenant’s traffic stays on one shard — but it also turns a large tenant into a hot shard. Use hash tags only when you want co-location.

Resharding and failover. When slots move between shards or a primary fails over, sharded subscriptions on the old owner are dropped and the client receives SUNSUBSCRIBE or a moved-slot error; cluster-aware clients resubscribe on the new owner. Messages published during the move can be missed, so ordered streams still need sequence numbers and a replay path, as in ordering WebSocket messages with sequence numbers.

Verification #

Confirm that publishes stop flooding the cluster. Compare per-node pub/sub counters before and after the switch while running the same load:

# On each cluster node: total messages this node has had to process for pub/sub.
for n in redis-0 redis-1 redis-2; do
echo "$n: $(redis-cli -h $n INFO stats | grep -E 'pubsub|total_net_input' | tr '\r\n' ' ')"
done
# Channels this node owns sharded subscribers for (Redis 7+).
redis-cli -h redis-1 PUBSUB SHARDCHANNELS 'room:*' | wc -l
redis-cli -h redis-1 PUBSUB SHARDNUMSUB room:42

After the switch, each node’s pub/sub input should be roughly its share of channels rather than the whole cluster’s publish rate, and cluster-bus bandwidth should drop sharply. End to end, verify with two WebSocket nodes that a message published to a room reaches members connected to both nodes, and nothing reaches nodes with no members in that room.

Classic vs sharded pub/sub for WebSocket fan-out Classic pub/sub works on any Redis version and supports pattern subscriptions but sends every publish to every cluster node and does not scale with shards; sharded pub/sub needs Redis 7, routes publishes to the owning shard and scales with shards, but has no pattern subscriptions. Classic vs sharded pub/sub for WebSocket fan-out Classic pub/sub Sharded pub/sub Redis version any 7.0+ Cluster traffic per publish every node owning shard only Scales by adding shards no yes Pattern subscriptions PSUBSCRIBE not supported Single-instance Redis fine works, no benefit If you depend on PSUBSCRIBE patterns, restructure channels before switching
On a cluster, sharded pub/sub is the only form that scales.

Operational checklist #

FAQ #

Why doesn’t Redis pub/sub scale in cluster mode? #

Classic PUBLISH is broadcast to every node in the cluster over the cluster bus, because subscribers can be on any node. Every shard processes every message, so adding shards adds load instead of capacity. Sharded pub/sub fixes this by routing channels to slots.

Do I need sharded pub/sub on a single Redis instance? #

No. With one instance there is nothing to shard; classic pub/sub is fine. Sharded pub/sub matters only on Redis Cluster (or managed services that shard for you).

Can I use pattern subscriptions with sharded pub/sub? #

No — SSUBSCRIBE takes exact channel names. Replace pattern-based designs with explicit channels per room and node-level reference counting.

Does sharded pub/sub guarantee delivery? #

No. Like classic pub/sub it is fire-and-forget: messages published while a subscriber is disconnected or during a slot move are lost. Use Redis Streams or another log where replay is required — see Redis Streams vs pub/sub for WebSocket fan-out.

Back to Redis Pub/Sub Fan-Out.