Skip to content

Pub/Sub

Verified on Redis 8.10.2 (redis:8.10.2-alpine), ioredis 6.0.0, Node 24, Apple-silicon Mac. The # cli/... transcript is replayed by the course harness; the subscriber output and the two @run scripts are real runs (a subscribed redis-cli blocks, so it cannot be a replayed transcript). Delivery semantics: Redis Pub/Sub docs.

  1. A client calls SUBSCRIBE channel and starts listening. In RESP2 it may then only issue subscription commands; in RESP3 it may run anything (next sections).
  2. Another client calls PUBLISH channel message. Redis pushes it to every subscriber connected at that moment and returns how many there were.
  3. Nothing is stored. With no subscribers the message is discarded, and a subscriber that reconnects gets nothing that was sent while it was away. The docs call this at-most-once delivery.

Channels are just names: they exist while somebody is subscribed and need no creation. Pub/Sub is not keyspace data either: it lives outside the dataset, so nothing is written to RDB/AOF for it, and it ignores SELECT (I published from db 5 with ioredis and a subscriber on db 0 received it).

A subscribed redis-cli cannot be replayed as a transcript because it blocks, so this is a real capture: SUBSCRIBE news sports (run with timeout 3 and --no-raw) in one shell, two PUBLISH calls from another (each returned 1):

Terminal window
$ redis-cli --no-raw SUBSCRIBE news sports
1) "subscribe"
2) "news"
3) (integer) 1
1) "subscribe"
2) "sports"
3) (integer) 2
1) "message"
2) "news"
3) "Breaking: Redis is fast"
1) "message"
2) "sports"
3) "goal"

Each confirmation is [kind, channel, total subscriptions on this connection], one per channel (the counter went 1, 2). Each push is ["message", channel, payload].

On the publishing side nothing needs a subscriber to exist, and PUBSUB inspects the state. Nobody was listening here, so everything is zero:

cli/pubsub-streams-pubsub-1.redis
127.0.0.1:6379> PUBLISH news "nobody is listening"
(integer) 0
127.0.0.1:6379> PUBSUB CHANNELS
(empty array)
127.0.0.1:6379> PUBSUB NUMSUB news
1) "news"
2) (integer) 0
127.0.0.1:6379> PUBSUB NUMPAT
(integer) 0
127.0.0.1:6379> SPUBLISH orders "o-1"
(integer) 0

PUBLISH returned 0: the message went nowhere and is gone. PUBSUB CHANNELS lists channels with at least one subscriber, PUBSUB NUMSUB ch counts the subscribers of a channel (not pattern subscribers) and PUBSUB NUMPAT counts pattern subscriptions.

PUBLISH news "Breaking: Redis is fast"
PUBLISH news "Another update"
PUBSUB NUMSUB news

UNSUBSCRIBE [channel ...] replies once per channel with the remaining count. With no argument it drops all of them, one reply each. Raw replies I captured (SUBSCRIBE a b, then UNSUBSCRIBE a, then UNSUBSCRIBE): ["unsubscribe","a",1], then ["unsubscribe","b",0]. When the count reaches 0 the connection returns to normal mode. (The (nil) reply shown in older tutorials appears only when you unsubscribe while subscribed to nothing.)

In RESP2 a subscribed connection is locked to SUBSCRIBE/UNSUBSCRIBE/PSUBSCRIBE/PUNSUBSCRIBE/SSUBSCRIBE/SUNSUBSCRIBE, PING, QUIT and RESET. The raw server reply to a GET on such a connection:

Terminal window
-ERR Can't execute 'get': only (P|S)SUBSCRIBE / (P|S)UNSUBSCRIBE / PING / QUIT / RESET are allowed in this context

After HELLO 3 (RESP3) the same GET simply answers (null), because pushes travel as a separate push type and do not collide with replies. The docs state the same. ioredis 6 speaks RESP3 by default, which changes what older tutorials say about “you need two connections”:

src/pubsub-streams-pubsub-basic.ts
// @run
import { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;
const sub = new Redis(url); // connection 1: subscribe only
const pub = new Redis(url); // connection 2: everything else
const got: string[] = [];
sub.on('message', (channel, message) => got.push(`${channel}: ${message}`));
// subscribe() resolves with the number of channels this connection now listens to
console.log('subscribed to', await sub.subscribe('news', 'sports'), 'channels'); // 2
// PUBLISH returns how many subscribers received it
console.log('delivered to', await pub.publish('news', 'Redis 8 is out')); // 1
console.log('delivered to', await pub.publish('nobody-listens', 'hello?')); // 0 -> gone forever
// PUBSUB introspection runs on the normal connection
console.log(await pub.pubsub('CHANNELS')); // [ 'news', 'sports' ] (any order)
console.log(await pub.pubsub('NUMSUB', 'news', 'nobody-listens')); // [ 'news', 1, 'nobody-listens', 0 ]
// ioredis 6 speaks RESP3 by default, so a subscribed connection may still run normal commands
console.log('RESP3 GET on the subscriber ->', await sub.get('x')); // null
// force RESP2 and the subscriber is locked to (P|S)SUBSCRIBE/UNSUBSCRIBE/PING/QUIT/RESET
const legacy = new Redis(url, { protocol: 2 });
await legacy.subscribe('legacy');
try { await legacy.get('x'); } catch (e) { console.log('RESP2 error:', (e as Error).message); }
legacy.disconnect();
await new Promise((r) => setTimeout(r, 50)); // let the push arrive
console.log(got); // [ 'news: Redis 8 is out' ]
// unsubscribe, then a publish reaches nobody
await sub.unsubscribe('news', 'sports');
console.log('delivered to', await pub.publish('news', 'after unsubscribe')); // 0
await sub.quit();
await pub.quit();

Real output (this Mac):

subscribed to 2 channels
delivered to 1
delivered to 0
[ 'sports', 'news' ]
[ 'news', 1, 'nobody-listens', 0 ]
RESP3 GET on the subscriber -> null
RESP2 error: Connection in subscriber mode, only subscriber commands may be used
[ 'news: Redis 8 is out' ]
delivered to 0

Even though RESP3 permits it, keep publishing on a separate connection: a subscriber connection is a long-lived receiver with its own backpressure (below), and libraries with protocol: 2 (older ioredis, or forced) throw the error above. PUBSUB CHANNELS order is unspecified. The RESP2 error text is ioredis’ own client-side message; the server’s raw text is the -ERR Can't execute 'get'... line.

SSUBSCRIBE, SPUBLISH and SUNSUBSCRIBE are the sharded variants, delivered as smessage. In a cluster a shard channel is hashed to a slot like a key and messages stay inside that slot’s shard instead of being broadcast to every node (docs, “Sharded Pub/Sub”; not proven here, we ran a standalone server). They work on a standalone server too: I ran ssubscribe('orders') and spublish('orders','o-1') in ioredis and got 1 delivery and the event smessage orders o-1; SPUBLISH to a channel nobody listens on returns 0, and PUBSUB SHARDCHANNELS / SHARDNUMSUB show orders with 1 subscriber. Use them when you run Cluster and want Pub/Sub load spread across shards.

Pub/Sub buffers pushes in the server’s per-client output buffer. The default for the pubsub class is client-output-buffer-limit pubsub 32mb 8mb 60: hard limit 32 MB, or 8 MB sustained for 60 seconds. Beyond it the server closes the connection. I made a subscriber that never reads and published 1 MiB messages:

src/pubsub-streams-pubsub-slow-subscriber.ts
// @run
import net from 'node:net';
import { Redis } from 'ioredis';
const url = new URL(process.env.REDIS_URL!);
const slow = net.connect(Number(url.port), url.hostname); // raw TCP subscriber
slow.on('error', () => {});
slow.write('SUBSCRIBE flood\r\n');
await new Promise((r) => setTimeout(r, 200));
slow.pause(); // ...that never reads its socket again
const pub = new Redis(process.env.REDIS_URL!);
const chunk = 'x'.repeat(1024 * 1024); // 1 MiB messages
let firstZero = -1;
for (let i = 0; i < 100; i++) {
if ((await pub.publish('flood', chunk)) === 0 && firstZero < 0) firstZero = i; // 0 = nobody left to deliver to
}
console.log('subscriber dropped after', firstZero, 'MiB published');
console.log((await pub.info('stats')).split('\r\n').filter((l) => l.includes('output_buffer')));
console.log((await pub.config('GET', 'client-output-buffer-limit'))[1]); // ... pubsub 33554432 8388608 60
pub.disconnect();
slow.destroy();

Real output:

subscriber dropped after 38 MiB published
[ 'client_output_buffer_limit_disconnections:2' ]
normal 0 0 0 slave 268435456 67108864 60 pubsub 33554432 8388608 60

Roughly 32 MB of buffer plus kernel socket buffers later, PUBLISH started returning 0 and the counter in INFO stats went up. (The counter is cumulative since the server started, so your number depends on earlier runs.) A consumer that falls behind loses all future messages until it reconnects, which is one more reason Pub/Sub is for live signals, not for data you must process.

An assistant wires “order paid” to a fulfilment worker with Pub/Sub:

🐛 Spot the bug

async function onPaymentSucceeded(order: Order) {
await db.markPaid(order.id);
await redis.publish('order:paid', JSON.stringify(order)); // fulfilment worker listens
}
Show the answer

If the fulfilment worker is down, restarting, or momentarily disconnected, publish resolves happily with 0 and the order is never fulfilled: nothing was stored, nothing will retry. Above, publish('nobody-listens', ...) returned 0 and PUBSUB NUMSUB showed 0 subscribers. The return value is the only signal, and this code ignores it. For work that must not be lost, append to a Stream (XADD order:paid * ...) and let a consumer group read it, or at least treat a 0 as an incident. Use Pub/Sub for what it is good at: live, disposable signals such as a cache-invalidation hint or a “typing…” indicator.

Pub/Sub is a live radio broadcast: only the people tuned in right now hear it, the station keeps no recording, and a listener who cannot keep up is hung up on. Use it for signals whose value expires immediately; use a Stream when someone must be able to catch up.

ChoiceBenefitCost
Pub/Sub for fan-outVery low latency, no storage, one publish reaches all current subscribersAt-most-once: absent subscribers lose the message, no replay, no ack
Many channels on one subscriber connectionFewer connectionsOne slow reader gets disconnected at the output-buffer limit and loses everything after it
Sharded Pub/Sub in ClusterTraffic stays inside the slot’s shardNo pattern subscriptions for shard channels (there is no SPSUBSCRIBE); needs 7.0+
Separate publisher connectionKeeps the receiver simple and independent of protocol versionOne more connection to manage
  • “A subscribed connection can never run commands.” True for RESP2 only; ioredis 6 (RESP3 default) ran GET on a subscriber and got null.
  • “PUBLISH returning 1 means the message was processed.” It counts subscribers the server pushed to, nothing more.
  • “Pub/Sub is durable if appendonly is on.” Messages are never written to RDB/AOF.
  • “Channels belong to a database.” They are global; SELECT does not scope them.
  • Using Pub/Sub for work that must not be lost — see the exercise above; use a Stream.
  • Ignoring the PUBLISH return value — 0 is your only evidence of a lost message.
  • Doing slow work inside the message handler — a lagging subscriber is disconnected at 32 MB (or 8 MB for 60 s); hand messages to a queue and return fast.

Real-world example

Redis’s own documentation states that Pub/Sub “exhibits at-most-once message delivery semantics” and points readers who need stronger guarantees to Streams (Redis Pub/Sub).

What does PUBLISH channel message return?
A RESP2 client runs SUBSCRIBE news. Can it also run GET mykey on that connection?
A publisher sends a message to a channel with zero subscribers. What happens?
Which position in a message push contains the channel name?
A subscriber stops reading its socket while you keep publishing 1 MiB messages. What happens on default settings?
Order events must never be lost if the fulfilment worker restarts. What should carry them?