Pub/Sub Limitations and When to Use Streams Instead
Redis Pub/Sub lets one client run PUBLISH on a named channel and have it instantly delivered to every client currently subscribed to that channel – there is no queue, no storage, and no history. It is extremely fast and simple, which makes it a natural fit for ephemeral broadcasts like live chat notifications or cache-invalidation pings. But that same simplicity means Pub/Sub silently drops a message the instant nobody is listening, gives you no acknowledgment that a subscriber actually processed it, and cannot replay anything after the fact. This lesson covers exactly where Pub/Sub breaks down in production, and shows how Redis Streams – a separate, persistent data type – solves the same publish/consume problem with durability, replay, and consumer groups.
How Redis Pub/Sub Works
Pub/Sub is built directly into the Redis server as an in-memory routing table, not as a data type stored in the keyspace. When a client runs SUBSCRIBE notifications:user:1001, Redis adds that client’s connection to a list associated with the channel name notifications:user:1001 in an internal dictionary. When another client runs PUBLISH notifications:user:1001 "Your order has shipped", Redis looks up that dictionary entry and, for every currently subscribed connection, writes the message directly onto that client’s output buffer. PUBLISH returns the number of clients the message was delivered to.
Because Redis processes one command at a time on a single thread, this fan-out is atomic with respect to every other command: no other command can run in the middle of a PUBLISH, so every subscriber observes the message at a consistent point relative to the rest of the command stream. But atomic delivery is not the same as durable delivery. The message is never written to a key, never touches the RDB snapshot or the AOF log, and is never buffered anywhere beyond the receiving client’s own output socket. If zero clients are subscribed at the moment PUBLISH runs, the message is gone forever – PUBLISH still returns (integer) 0 and reports no error, because from Redis’s point of view nothing went wrong: it successfully delivered the message to zero recipients.
PSUBSCRIBE extends this with glob-style pattern matching (news.* matches news.sports and news.weather), so a client can listen to a family of channels without knowing their exact names in advance. Every PUBLISH now has to check both the exact-channel subscriber list and every registered pattern, which is why its time complexity is O(N+M): N direct subscribers plus M registered patterns.
The Limitations of Pub/Sub
These properties are fine for some workloads and disqualifying for others. The concrete failure modes are:
- No persistence or replay. A message exists only for the instant it takes to fan out to currently-connected subscribers. A client that connects one second late has permanently missed it, and there is no command that can retrieve it afterward.
- At-most-once delivery, no acknowledgment. A subscriber cannot tell Redis “I received message X, don’t consider it lost” because Redis never considered it “in flight” to begin with. If the subscriber crashes mid-processing, Redis has no idea and does nothing differently.
- No real consumer groups. Subscribing to the same channel from three worker processes does not spread the work across them – all three receive every message. Pub/Sub can broadcast to many listeners, but it cannot load-balance a stream of work across a pool of workers the way a task queue needs to.
- Slow subscribers get disconnected, silently losing messages. Redis enforces a
client-output-buffer-limit pubsubcap; if a subscriber can’t keep up and its output buffer exceeds that limit, Redis forcibly closes the connection to protect server memory. Every message that was queued for that client, and every message published afterward before it reconnects, is lost. - Invisible to normal keyspace tooling. Channels are not keys. They don’t show up in
SCAN, don’t have aTYPE, and can’t be inspected withTTLorOBJECT ENCODING. The only introspection is the dedicatedPUBSUBsubcommands. - No backpressure to the publisher.
PUBLISH“succeeds” (returns a receiver count) regardless of whether those receivers are keeping up, so a fast publisher can overwhelm slow subscribers with no signal until they’re disconnected.
Syntax
Pub/Sub commands:
PUBLISH channel message
SUBSCRIBE channel [channel ...]
PSUBSCRIBE pattern [pattern ...]
UNSUBSCRIBE [channel ...]
PUNSUBSCRIBE [pattern ...]
PUBSUB CHANNELS [pattern]
PUBSUB NUMSUB [channel ...]
PUBSUB NUMPAT
channel/pattern– the destination name; a pattern uses glob syntax (*,?, character classes).message– an arbitrary string payload; Redis does not interpret it.PUBSUB CHANNELSlists channels that currently have at least one subscriber, optionally filtered by a glob pattern.PUBSUB NUMSUBreturns the subscriber count for each named channel.PUBSUB NUMPATreturns the number of patterns currently registered viaPSUBSCRIBE.
The Streams commands that address these limitations:
XADD key [NOMKSTREAM] [MAXLEN|MINID [=|~] threshold] (* or explicit-id) field value [field value ...]
XLEN key
XRANGE key start end [COUNT count]
XREAD [COUNT count] STREAMS key [key ...] id [id ...]
XGROUP CREATE key groupname (id or $) [MKSTREAM]
XREADGROUP GROUP groupname consumer [COUNT count] STREAMS key [key ...] id [id ...]
XACK key groupname id [id ...]
XPENDING key groupname
key– the stream’s key name.*inXADDasks Redis to auto-generate a strictly increasing ID (a millisecond timestamp plus a sequence number, e.g.1723300000000-0); you can also supply your own ID explicitly.MAXLEN/MINIDcaps the stream’s size at insert time, trimming old entries.start/endinXRANGEaccept-and+to mean “the lowest possible ID” and “the highest possible ID”.XGROUP CREATE ... $starts the group’s cursor at the end of the stream (only new entries);0starts it at the beginning (replay everything).>as the ID inXREADGROUPmeans “give me entries never delivered to any consumer in this group before”.XACKremoves an entry from the group’s pending entries list (PEL) once a consumer has finished processing it.
Examples
Example 1: A published message with no subscribers is lost
PUBLISH notifications:user:1001 "Your order has shipped"
PUBSUB CHANNELS
PUBSUB NUMSUB notifications:user:1001
Output:
(integer) 0
(empty array)
1) "notifications:user:1001"
2) (integer) 0
PUBLISH returns 0 because no client was subscribed at that instant – the message was not queued anywhere, it simply ceased to exist. PUBSUB CHANNELS returns an empty array because a channel only appears in that list while at least one client is actively subscribed to it; publishing to a channel does not “create” it in any lasting sense. PUBSUB NUMSUB confirms zero current subscribers for that channel name.
Example 2: A live subscriber receiving a published message
This requires two separate redis-cli connections open at the same time, so it’s shown here for illustration rather than as a single runnable block:
# Terminal A - subscriber (this call blocks, waiting for messages)
SUBSCRIBE notifications:user:1001
# Terminal B - publisher, run in a second redis-cli session
PUBLISH notifications:user:1001 "Your order has shipped"
Output:
# Terminal A prints immediately on subscribing:
1) "subscribe"
2) "notifications:user:1001"
3) (integer) 1
# ...then, the moment Terminal B publishes, Terminal A prints:
1) "message"
2) "notifications:user:1001"
3) "Your order has shipped"
# Terminal B prints:
(integer) 1
Notice the receiver count is now 1 instead of 0, because Terminal A was subscribed when the publish happened. This is the entire Pub/Sub contract: deliver to whoever happens to be listening right now, and nothing more.
Example 3: Streams remember history a late reader can still see
XADD notifications:user:1001 * message "Your order has shipped" type "order_update"
XADD notifications:user:1001 * message "Your order was delivered" type "order_update"
XLEN notifications:user:1001
XRANGE notifications:user:1001 - +
Output:
"1723300000000-0"
"1723300000001-0"
(integer) 2
1) 1) "1723300000000-0"
2) 1) "message"
2) "Your order has shipped"
3) "type"
4) "order_update"
2) 1) "1723300000001-0"
2) 1) "message"
2) "Your order was delivered"
3) "type"
4) "order_update"
Both entries are written to the stream and stay there. A consumer that connects long after these writes happened can still run XRANGE notifications:user:1001 - + and see every event from the beginning – the exact capability Pub/Sub is missing. The auto-generated IDs are strictly increasing, so they double as both a unique identifier and a timestamp.
Example 4: Consumer groups track who has acknowledged what
XADD orders:events * order_id "5001" status "created"
XGROUP CREATE orders:events order_processors 0
XREADGROUP GROUP order_processors worker-1 COUNT 1 STREAMS orders:events >
XPENDING orders:events order_processors
Output:
"1723300500000-0"
OK
1) 1) "1723300500000-0"
2) 1) "order_id"
2) "5001"
3) "status"
4) "created"
(integer) 1
"1723300500000-0"
"1723300500000-0"
1) 1) "worker-1"
2) "1"
XGROUP CREATE ... 0 creates a consumer group starting from the beginning of the stream. XREADGROUP then delivers the one pending entry to consumer worker-1 and simultaneously adds it to that group’s pending entries list (PEL) – Redis is now tracking that this specific entry was handed out but not yet confirmed. XPENDING confirms: one pending entry, its ID range, and that worker-1 holds it. In a real worker, the next step would be running XACK with the real ID printed above once processing succeeds – that removes it from the PEL. If worker-1 crashes before acknowledging, the entry stays in the PEL and another consumer can claim it with XCLAIM or XAUTOCLAIM. Pub/Sub has no equivalent of a PEL: a crashed subscriber’s in-flight message is simply gone.
How It Works Step by Step
What happens inside Redis for PUBLISH channel message:
- Redis looks up
channelin its in-memory pubsub dictionary (channel name to list of subscribed client connections). - It also checks the separate list of registered
PSUBSCRIBEpatterns and tests each one againstchannel. - For every matching connection, Redis writes a three-element reply (
"message", the channel name, the payload) directly onto that client’s output buffer. - It returns the total count of connections the message was written to. Nothing is stored; the dictionary lookup and the writes are the entire operation.
What happens for a Streams write and a group read:
XADDappends an entry to the stream’s underlying structure – a compact radix tree (internally called a “rax”) keyed by ID, with recent entries packed for efficiency. The entry becomes part of the key’s value, subject to the same RDB/AOF persistence as any other key.- If
MAXLEN/MINIDwas given, Redis trims the oldest entries from the other end of the tree until the stream fits the threshold. XGROUP CREATEattaches a named cursor (the group’s “last delivered ID”) to the stream, stored alongside it, plus an empty PEL for that group.XREADGROUP ... >advances the group’s cursor past any entries never handed to this group before, copies each into the calling consumer’s slice of the PEL (entry ID, consumer name, delivery time, delivery count), and returns them to the client.XACKdeletes the specified entry IDs from the PEL. The stream entries themselves are untouched – acknowledging only affects delivery bookkeeping, not the data.
Time Complexity Reference
| Command | Time Complexity | Notes |
|---|---|---|
PUBLISH |
O(N+M) | N = subscribers on the channel, M = registered PSUBSCRIBE patterns |
SUBSCRIBE / UNSUBSCRIBE |
O(N) | N = number of channels named in the call |
PUBSUB CHANNELS |
O(N) | N = number of currently active channels |
PUBSUB NUMSUB |
O(N) | N = number of channels requested |
XADD |
O(1) | Becomes O(N) when a MAXLEN/MINID trim removes N entries |
XLEN |
O(1) | – |
XRANGE / XREVRANGE |
O(log N + M) | N = stream length, M = entries returned |
XREAD / XREADGROUP |
O(log N + M) per stream | N = stream length, M = entries returned |
XACK |
O(M) | M = number of IDs acknowledged |
XGROUP CREATE |
O(1) | – |
XPENDING (summary form) |
O(1) | Extended form is O(M) for M entries returned |
Common Mistakes
Mistake 1: Assuming a published message is buffered for the next subscriber. As Example 1 showed, PUBLISH to a channel with zero current subscribers just discards the message – there’s no queue behind it waiting for someone to connect. If your use case needs “whoever shows up eventually will see this,” you need a persistent structure, not Pub/Sub:
XADD notifications:user:1001 * message "Your order has shipped"
Output:
"1723300800000-0"
A late-arriving reader can XRANGE from the beginning and still see it.
Mistake 2: Calling XREADGROUP against a group that was never created. Unlike Pub/Sub, where any client can just SUBSCRIBE to any channel name, Streams consumer groups must be explicitly created with XGROUP CREATE before anyone can read through them:
XADD logs:app * event "startup"
XREADGROUP GROUP missing_group worker-1 COUNT 1 STREAMS logs:app >
Output:
"1723300900000-0"
(error) NOGROUP No such key 'logs:app' or consumer group 'missing_group' in XREADGROUP with GROUP option
The fix is to create the group first (optionally with MKSTREAM so it also creates the stream if it doesn’t exist yet):
XGROUP CREATE logs:app missing_group 0 MKSTREAM
Output:
OK
Mistake 3: Treating Streams as free persistence and never trimming them. Because Streams solve Pub/Sub’s “no history” problem by keeping every entry, an unbounded stream (a sensor writing every second, for example) grows forever and can quietly consume all available memory. Cap it at write time with MAXLEN:
XADD sensor:temp:001 MAXLEN ~ 1000 * reading "22.5"
XLEN sensor:temp:001
Output:
"1723301000000-0"
(integer) 1
The ~ tells Redis it can trim approximately (rather than exactly) to 1000 entries, which is far cheaper because it avoids repacking the underlying structure on every single write.
Mistake 4: Not knowing a slow subscriber can be silently dropped. Redis limits how much unread data it will buffer for a Pub/Sub client:
CONFIG GET client-output-buffer-limit
Output:
1) "client-output-buffer-limit"
2) "normal 0 0 0 slave 268435456 67108864 60 pubsub 33554432 8388608 60"
The pubsub class here allows a 32 MB hard limit (or 8 MB sustained for 60 seconds) before Redis force-disconnects the client. If your subscriber can’t keep up with publish volume, it gets dropped and misses everything published after that point – with no error delivered to the publisher and no way for the subscriber to know what it missed.
Best Practices
- Use Pub/Sub only for messages where losing one occasionally is acceptable – live cache-invalidation pings, presence/typing indicators, broadcasting a config-reload signal to all app instances.
- Use Streams (with consumer groups) whenever you need at-least-once delivery, work distribution across multiple workers, or the ability to replay history.
- Always cap long-lived streams with
MAXLEN ~(orMINID ~if you trim by ID/time) at write time so they don’t grow unbounded. - Always
XACKan entry once it’s genuinely processed, and monitorXPENDINGfor entries stuck too long – that signals a stuck or crashed consumer. - If you must use Pub/Sub for something business-critical, pair it with a durable fallback (write the event to a Stream first, then also
PUBLISHas a low-latency notification) rather than relying on Pub/Sub alone. - Watch the
pubsubclass ofclient-output-buffer-limitin production and alert on subscriber disconnects – a disconnect there is a silent data-loss event, not just a reconnect. - Don’t try to build a work queue out of multiple subscribers on one channel – every subscriber gets every message, so you get duplicated work, not load balancing. Reach for Streams consumer groups instead.
Practice Exercises
- Exercise 1: Using two separate
redis-clisessions, subscribe to a channel namedchat:room:42in one, then publish three different messages to it from the other. Note which messages the subscriber receives, then close the subscriber, publish a fourth message, and reopen the subscription – confirm the fourth message is unrecoverable. - Exercise 2: Model the same chat room as a stream called
chat:room:42:eventsinstead. Add the same three messages withXADD, then create a consumer group calledreadersstarting from ID0. UseXREADGROUPto read all three, confirm withXPENDINGthat they’re all still unacknowledged, then acknowledge them one at a time withXACKand watch the pending count drop. - Exercise 3: Create a stream
tasks:signupand a consumer groupworkers. Read one entry as consumerworker-awithout acknowledging it. Assumeworker-athen crashed – look up howXCLAIMorXAUTOCLAIMwould letworker-btake over that pending entry, and write out the command you’d use.
Summary
- Pub/Sub is an in-memory, fire-and-forget broadcast:
PUBLISHdelivers only to clients subscribed at that exact instant, and the message is never stored anywhere. - A
PUBLISHto a channel with no subscribers returns0and silently discards the message – this is normal behavior, not an error. - Pub/Sub has no replay, no acknowledgment, no consumer groups, and can silently drop a slow subscriber via the
client-output-buffer-limit pubsubcap. - Redis Streams (
XADD,XRANGE,XREAD) persist every entry in the keyspace, so late readers can replay full history. - Stream consumer groups (
XGROUP CREATE,XREADGROUP,XACK,XPENDING) track exactly which entries were delivered to which consumer and haven’t yet been acknowledged, enabling true work distribution and crash recovery. - Choose Pub/Sub for cheap, latency-sensitive, loss-tolerant broadcasts; choose Streams whenever the message itself matters and must not be lost.
