Redis Pub/Sub Explained
Redis Pub/Sub is the backbone of most WebSocket scaling architectures. Here's how it works, what it's good at, and where it falls short.
If you've ever wondered how a message published on one server ends up delivered to a client connected to a completely different server — Redis Pub/Sub is usually the answer.
It's one of Redis's simpler features, and one of its most useful. Understanding it is essential for anyone building realtime systems that need to scale beyond a single process.
The Core Model
Redis Pub/Sub implements a message broker pattern. Publishers send messages to named topics (called channels). Subscribers listen to topics and receive every message published while they're connected.
Publishers and subscribers never communicate directly. Neither knows about the other's existence. Redis is the intermediary.
Publisher → PUBLISH chat:room-1 "hello"
↓
Redis channel: chat:room-1
↓
Subscriber A ← receives "hello"
Subscriber B ← receives "hello"
Subscriber C ← receives "hello"
The Commands
The Redis Pub/Sub interface is just three commands.
SUBSCRIBE
redis-cli
> SUBSCRIBE chat:room-1
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "chat:room-1"
3) (integer) 1
Once subscribed, the connection is in subscribe mode. It can only receive messages — it cannot execute other Redis commands.
PUBLISH
# From another Redis connection
> PUBLISH chat:room-1 "hello from the other side"
(integer) 2 # number of subscribers who received the message
PSUBSCRIBE (pattern matching)
# Subscribe to all chat channels at once
> PSUBSCRIBE chat:*
Using Redis Pub/Sub in Node.js
import { createClient } from 'redis';
const publisher = createClient({ url: process.env.REDIS_URL });
const subscriber = createClient({ url: process.env.REDIS_URL });
await publisher.connect();
await subscriber.connect();
// Subscribe
await subscriber.subscribe('chat:room-1', (message) => {
console.log('Received:', message);
// → fan out to local WebSocket connections
});
// Publish from anywhere in your app
await publisher.publish('chat:room-1', JSON.stringify({
text: 'Hello, room!',
userId: 42,
timestamp: Date.now(),
}));
Note the two separate clients. A client in subscribe mode cannot publish, so you always need one connection per role.
How WebSocket Servers Use It
This is where Redis Pub/Sub becomes genuinely useful. A single WebSocket server process holds thousands of local connections. But your fleet has multiple server processes, and a message published by one client might need to reach clients connected to a different server.
Redis Pub/Sub provides the cross-node delivery:
Client A → Server 1 → redis PUBLISH chat:room-1 "{message}"
↓
Server 1 listener → deliver to local subscribers on room-1
Server 2 listener → deliver to local subscribers on room-1
Server 3 listener → deliver to local subscribers on room-1
Each server subscribes to Redis topics for the channels that have local subscribers. When a message arrives via Redis, the server fans it out to its local connections.
Ref-counted subscriptions
You don't want every server subscribed to every channel in Redis — that's wasteful. Use reference counting: subscribe to a Redis topic when the first local client joins a channel, unsubscribe when the last leaves.
const refCounts = new Map();
function localJoin(channelName, connection) {
const count = refCounts.get(channelName) ?? 0;
if (count === 0) {
subscriber.subscribe(`realtime:${channelName}`, onRedisMessage);
}
refCounts.set(channelName, count + 1);
}
function localLeave(channelName, connection) {
const count = refCounts.get(channelName) - 1;
refCounts.set(channelName, count);
if (count === 0) {
subscriber.unsubscribe(`realtime:${channelName}`);
}
}
What Redis Pub/Sub Does Not Do
Redis Pub/Sub is intentionally minimal. Its limitations matter as much as its capabilities.
No message persistence
Published messages are delivered immediately to current subscribers and then discarded. If a subscriber is offline when a message is published, it never receives it. There is no queue, no inbox, no catch-up.
No acknowledgments
Redis doesn't know whether subscribers successfully processed a message. If a subscriber crashes mid-delivery, the message is gone. Pub/Sub is at-most-once delivery.
No history
There's no way to ask "what messages were published to this channel in the last hour?" Messages exist only in flight.
When These Limitations Matter
For many WebSocket use cases, at-most-once delivery is fine:
- Typing indicators — if you miss one, it doesn't matter; the next arrives shortly
- Live cursors — position updates supersede each other
- Dashboard metrics — a missed data point is filled by the next update
For use cases where messages must not be lost — chat history, order notifications, audit logs — you need persistence. Store messages in a database on publish and use Pub/Sub only for real-time delivery. Clients fetch history from the database; Pub/Sub handles the live stream.
Redis Streams as an Alternative
If you need persistence with fan-out, Redis Streams (XADD, XREAD, XREADGROUP) provide a persistent, consumer-group-based alternative. Subscribers can replay from any point in the stream. The trade-off is higher complexity and more Redis memory usage.
Pub/Sub remains the right tool for low-latency, ephemeral message delivery — which covers the majority of WebSocket use cases.
How Apinator Uses This Pattern
Apinator uses Redis Pub/Sub internally for cross-node fanout. Each data plane node subscribes to Redis topics for channels with local subscribers. When you publish an event:
await apinator.publish('chat:room-1', 'message', { text: 'hello' });
The message is routed through Redis to every node that has subscribers for that channel, and delivered to those clients — regardless of which server they're connected to. The ref-counting and fanout are handled for you.
Understanding this model helps you reason about delivery semantics: at-most-once for real-time events. For critical messages that must survive reconnections, pair the WebSocket event with a database-backed API that clients can query on reconnect.