NATS vs Redis for WebSocket fan-out #
Your WebSocket fleet uses Redis pub/sub to deliver messages between nodes, and it works — until the fan-out traffic starts to compete with the cache traffic on the same Redis, a pattern subscription makes every node process every message, and a network blip loses messages nobody can replay. The usual next question is whether to move fan-out to a system designed for messaging, and NATS is the most common candidate: a lightweight broker with hierarchical subjects, wildcard subscriptions, clustering built in, and JetStream for persistence and replay. Both are good tools. They differ in routing model, delivery guarantees, operational shape, and what else your infrastructure already depends on — and those differences decide which fits a real-time backend.
Root cause #
A WebSocket fleet needs a bus with a specific profile: very many channels (one per room, document or user), each subscribed to by only the few nodes hosting its members; low latency, because users are waiting; high message rates in bursts; and, for some streams, the ability to replay what a node missed during a disconnect or deploy.
Redis offers two relevant primitives. Pub/sub is fast and simple but fire-and-forget, and in cluster mode classic pub/sub broadcasts to every node unless you use sharded pub/sub (covered in Redis sharded pub/sub for WebSocket fan-out). Streams add persistence and replay but need consumer bookkeeping and trimming. Redis is often already in the stack as a cache and session store, which makes it the default — and means fan-out competes with everything else on it.
NATS is built around subject-based routing: subjects like room.42.chat are hierarchical, subscribers can use wildcards (room.42.>), and the server routes each message only to interested subscribers, including across a cluster via interest propagation. Core NATS is at-most-once like Redis pub/sub; JetStream adds persisted streams with replay, acknowledgements and consumer state.
Resolution #
For a new fan-out bus, structure it the same way regardless of broker: each WebSocket node subscribes to the subjects for rooms that have local members, publishes go to the room’s subject, and streams that need replay use the persistent mode. Here is the NATS version of the bridge, with core NATS for ephemeral traffic and JetStream for replayable room history:
import { connect, StringCodec, AckPolicy, DeliverPolicy, type Subscription } from 'nats';
import type { WebSocket } from 'ws';
const nc = await connect({ servers: ['nats://nats-0:4222', 'nats://nats-1:4222'] });
const js = nc.jetstream();
const sc = StringCodec();
// Ephemeral: cursors and typing — core NATS, at-most-once, lowest latency.
const ephemeralSubs = new Map<string, Subscription>();
export function subscribeEphemeral(room: string, deliver: (msg: string) => void) {
if (ephemeralSubs.has(room)) return;
const sub = nc.subscribe(`room.${room}.ephemeral`); // routed only where there is interest
ephemeralSubs.set(room, sub);
(async () => { for await (const m of sub) deliver(sc.decode(m.data)); })();
}
// Replayable: chat history — a JetStream stream capturing room.*.chat, kept for a day.
await (await js.jetStreamManager()).streams.add({
name: 'ROOM_CHAT', subjects: ['room.*.chat'], max_age: 24 * 60 * 60 * 1e9, // ns
});
export async function subscribeChat(room: string, afterSeq: number, deliver: (msg: string, seq: number) => void) {
// An ordered, ephemeral consumer per node+room, starting after the last seq this node saw.
const consumer = await js.consumers.get('ROOM_CHAT', {
filterSubjects: [`room.${room}.chat`],
...(afterSeq > 0 ? { opt_start_seq: afterSeq + 1 } : {}),
});
const messages = await consumer.consume();
(async () => { for await (const m of messages) deliver(sc.decode(m.data), m.seq); })();
}
export function publishEphemeral(room: string, payload: object) {
nc.publish(`room.${room}.ephemeral`, sc.encode(JSON.stringify(payload)));
}
export async function publishChat(room: string, payload: object) {
const ack = await js.publish(`room.${room}.chat`, sc.encode(JSON.stringify(payload)));
return ack.seq; // stream sequence = ordering + resume point
}
The stream sequence returned by js.publish doubles as the per-room ordering and resume token clients use after a reconnect, exactly the role Redis Stream ids play in Redis Streams vs pub/sub for WebSocket fan-out. Note that JetStream stream sequences are per stream, not per subject; if clients need gap detection per room, carry a per-room counter in the payload as well.
When should you move? Stay on Redis when fan-out volume is moderate, Redis is already operated well, and you use sharded pub/sub or Streams correctly. Move to NATS when fan-out traffic is large enough to deserve dedicated infrastructure, when subject wildcards would simplify routing (per-tenant or per-region hierarchies), or when you want at-most-once and replayable delivery from one system with the same subject model.
Edge cases #
Slow consumers. NATS protects itself by disconnecting subscribers that cannot keep up (slow consumer errors), and Redis buffers output for pub/sub clients up to client-output-buffer-limit pubsub before disconnecting them. Either way, a WebSocket node that falls behind loses its subscription. Monitor for it, and make nodes resubscribe and resume rather than silently missing messages.
Subject and channel cardinality. Both systems handle hundreds of thousands of subjects or channels, but per-subscription overhead adds up. Subscribe per room with local members, never per connection.
Operational fit. NATS clusters are straightforward to run and have a small footprint, but they are another system with its own upgrade, monitoring and security story. If your team runs Redis well and nothing else, that experience is worth a lot.
Verification #
Benchmark with your own message sizes and topology rather than generic numbers. The nats bench tool and redis-benchmark give baselines; the more useful test is end to end: publish timestamped messages into a room with members on several WebSocket nodes and measure publish-to-socket-write latency on each node, under your peak rate.
# NATS: publish/subscribe throughput with 4 subscribers and 256-byte messages.
nats bench room.bench --pub 2 --sub 4 --msgs 1000000 --size 256
# Check for slow-consumer disconnects during load (server monitoring endpoint).
curl -s http://nats-0:8222/connz?subs=1 | jq '.connections[] | select(.pending_bytes > 1000000) | .name'
Then run a failure test: restart one broker node and one WebSocket node during load, and confirm replayable streams resume without gaps while ephemeral ones simply continue.
Operational checklist #
FAQ #
Is NATS faster than Redis for pub/sub? #
Both deliver small messages in well under a millisecond on a local network, and both handle very high message rates. Raw speed rarely decides it; routing model, persistence options and operational fit do.
Does NATS guarantee delivery? #
Core NATS does not — it is at-most-once. JetStream persists messages and supports acknowledgements, redelivery and replay from a sequence, which is what WebSocket resume needs.
Can I use Redis pattern subscriptions instead of NATS wildcards? #
You can, but PSUBSCRIBE makes Redis match every published message against every pattern, and patterns do not combine with sharded pub/sub. NATS wildcards are routed natively and cheaply.
What about Kafka? #
Kafka is a durable log optimized for throughput and retention, not low-latency fan-out to many small channels. It is a good source for WebSocket fan-out, bridged through a service, as described in a Kafka to WebSocket bridge.
Related #
- Redis Sharded Pub/Sub for WebSocket Fan-Out — making Redis Cluster scale for fan-out.
- Redis Streams vs Pub/Sub for WebSocket Fan-Out — replay on Redis.
- Kafka to WebSocket Bridge — when events originate in Kafka.
- Resuming WebSocket Sessions After Reconnect — the client side of replay.
Back to Redis Pub/Sub Fan-Out.