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.
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.
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.
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.
Related #
- Scaling WebSocket Broadcast with Redis Pub/Sub — the bridge architecture this upgrades.
- Redis Streams vs Pub/Sub for WebSocket Fan-Out — when delivery must survive gaps.
- NATS vs Redis for WebSocket Fan-Out — a broker built for subject routing.
- WebSocket Rooms and Channel Subscriptions — the local membership registry.
Back to Redis Pub/Sub Fan-Out.