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@runscripts are real runs (a subscribedredis-cliblocks, so it cannot be a replayed transcript). Delivery semantics: Redis Pub/Sub docs.
How Pub/Sub works
Section titled “How Pub/Sub works”- A client calls
SUBSCRIBE channeland starts listening. In RESP2 it may then only issue subscription commands; in RESP3 it may run anything (next sections). - Another client calls
PUBLISH channel message. Redis pushes it to every subscriber connected at that moment and returns how many there were. - 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).
What a subscriber sees
Section titled “What a subscriber sees”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):
$ redis-cli --no-raw SUBSCRIBE news sports1) "subscribe"2) "news"3) (integer) 11) "subscribe"2) "sports"3) (integer) 21) "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:
127.0.0.1:6379> PUBLISH news "nobody is listening"(integer) 0127.0.0.1:6379> PUBSUB CHANNELS(empty array)127.0.0.1:6379> PUBSUB NUMSUB news1) "news"2) (integer) 0127.0.0.1:6379> PUBSUB NUMPAT(integer) 0127.0.0.1:6379> SPUBLISH orders "o-1"(integer) 0PUBLISH 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 newsUNSUBSCRIBE and the count
Section titled “UNSUBSCRIBE and the count”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.)
Subscriber connections: RESP2 vs RESP3
Section titled “Subscriber connections: RESP2 vs RESP3”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:
-ERR Can't execute 'get': only (P|S)SUBSCRIBE / (P|S)UNSUBSCRIBE / PING / QUIT / RESET are allowed in this contextAfter 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”:
// @runimport { Redis } from 'ioredis';
const url = process.env.REDIS_URL!;const sub = new Redis(url); // connection 1: subscribe onlyconst 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 toconsole.log('subscribed to', await sub.subscribe('news', 'sports'), 'channels'); // 2
// PUBLISH returns how many subscribers received itconsole.log('delivered to', await pub.publish('news', 'Redis 8 is out')); // 1console.log('delivered to', await pub.publish('nobody-listens', 'hello?')); // 0 -> gone forever
// PUBSUB introspection runs on the normal connectionconsole.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 commandsconsole.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/RESETconst 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 arriveconsole.log(got); // [ 'news: Redis 8 is out' ]
// unsubscribe, then a publish reaches nobodyawait 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 channelsdelivered to 1delivered to 0[ 'sports', 'news' ][ 'news', 1, 'nobody-listens', 0 ]RESP3 GET on the subscriber -> nullRESP2 error: Connection in subscriber mode, only subscriber commands may be used[ 'news: Redis 8 is out' ]delivered to 0Even 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.
Sharded Pub/Sub (7.0+)
Section titled “Sharded Pub/Sub (7.0+)”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.
A slow subscriber is cut off
Section titled “A slow subscriber is cut off”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:
// @runimport 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 subscriberslow.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 messageslet 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 60Roughly 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.
Reading AI-generated code
Section titled “Reading AI-generated code”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.
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Pub/Sub for fan-out | Very low latency, no storage, one publish reaches all current subscribers | At-most-once: absent subscribers lose the message, no replay, no ack |
| Many channels on one subscriber connection | Fewer connections | One slow reader gets disconnected at the output-buffer limit and loses everything after it |
| Sharded Pub/Sub in Cluster | Traffic stays inside the slot’s shard | No pattern subscriptions for shard channels (there is no SPSUBSCRIBE); needs 7.0+ |
| Separate publisher connection | Keeps the receiver simple and independent of protocol version | One more connection to manage |
Common Misconceptions
Section titled “Common Misconceptions”- “A subscribed connection can never run commands.” True for RESP2 only; ioredis 6 (RESP3 default) ran
GETon a subscriber and gotnull. - “
PUBLISHreturning1means the message was processed.” It counts subscribers the server pushed to, nothing more. - “Pub/Sub is durable if
appendonlyis on.” Messages are never written to RDB/AOF. - “Channels belong to a database.” They are global;
SELECTdoes not scope them.
Common mistakes
Section titled “Common mistakes”- Using Pub/Sub for work that must not be lost — see the exercise above; use a Stream.
- Ignoring the
PUBLISHreturn value —0is your only evidence of a lost message. - Doing slow work inside the
messagehandler — 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).