In short. The choice between Pub/Sub and Streams in Redis comes down to one question: what should happen to the message if, at the moment you publish it, nobody is reading? With Pub/Sub the answer is "it disappears": the documentation speaks of at-most-once delivery, that is, at most one time, and PUBLISH tells you how many listeners it reached (in my experiments, 0 when nobody was connected). With Streams the message stays in the stream until you trim it, and a consumer group remembers which messages it has delivered and which it hasn't: if a consumer dies halfway, the message stays "pending" and another one can claim it. The price is that with Streams you have to acknowledge every message (XACK), check the queue of missing acknowledgements and decide when to trim the stream, otherwise it grows without limit. Below you will find both strategies run on Redis 8.0.5 and 7.4.7, with the real outputs. There is also a note of honesty: this site's backend today uses neither, and I tell you why the article exists anyway.
Method. All the commands were run on 8 November 2026 on throwaway instances (port 6391, Redis 8.0.5; a Redis 7.4.7 container for the repetitions). Where the outputs coincide I show only one; where they change I say so.
Revision of 13 November 2026. In this revision I did not start any server: the outputs of the blocks are those of 8 November and I did not reproduce them. Only one datum allows a check without a server: the message identifiers start with a timestamp in milliseconds, and 1794122474177 corresponds to 8 November 2026, 07:21 UTC, the day of the test. The rest I reread on today's official documentation, and what is not in my outputs I declare "not run".
The problem: a message with no reader
One service produces an event ("order confirmed") and another has to react. As long as both are on, any mechanism seems to work. The flaw shows up the day the reader is stopped for a deploy, restarting or disconnected for an instant of network: then the choice made at design time decides whether you lose the event or find it again on return. Redis gives you two tools with two opposite answers, and it is useful to have seen both before choosing one out of habit.
Pub/Sub: the message lives as long as someone is listening
Publishing with no listener
The first experiment is the simplest: I publish on a channel that nobody has subscribed to.
$ redis-cli -p 6391 publish ordini "ordine-1"
0
The answer is 0: the number of clients that received the message. Redis wrote nothing anywhere, so the message no longer exists. No error, no warning: if you don't look at the return value, you won't notice.
With a connected listener, and after it disconnects
Now I subscribe to a channel in the background, writing to a file whatever arrives, publish, disconnect the listener, publish again and reconnect it:
$ redis-cli -p 6391 subscribe ordini > ricevuti.txt & # listener connected
$ redis-cli -p 6391 publish ordini "ordine-2"
1
$ kill %1 # the listener disconnects
$ redis-cli -p 6391 publish ordini "ordine-3"
0
$ redis-cli -p 6391 subscribe ordini > ricevuti2.txt & # it reconnects
$ cat ricevuti.txt ricevuti2.txt
subscribe
ordini
1
message
ordini
ordine-2
subscribe
ordini
1
The first listener received ordine-2. ordine-3, published while nobody was there, reaches nobody, not even on reconnection: in the second file there is only the subscription confirmation. On 7.4.7 the result is identical.
What the documentation promises
A useful variant is subscription by pattern: with PSUBSCRIBE a listener receives all the channels that match a template. The return value of PUBLISH counts the recipients here too:
$ redis-cli -p 6391 psubscribe 'ordini:*' > pat.txt &
$ redis-cli -p 6391 publish ordini:italia "a"
1
$ redis-cli -p 6391 publish ordini:francia "b"
1
$ redis-cli -p 6391 publish fatture "c"
0
$ cat pat.txt
psubscribe
ordini:*
1
pmessage
ordini:*
ordini:italia
a
pmessage
ordini:*
ordini:francia
b
The fatture channel doesn't match the template and its PUBLISH returns 0: again, no error.
This is not a defect to fix, it is the contract. Redis's Pub/Sub page says it in explicit words: delivery has at-most-once semantics, a message is delivered one time if it is delivered at all, and if the subscriber fails to handle it (an error, a network disconnection) there is no way for it to be sent again. When it fits:
- The data is an ephemeral notification that loses value immediately: online presence, a cursor, an update that a client will receive again in complete form on the next cycle.
- You have many listeners and each must see every message: Pub/Sub does broadcast.
- You can tolerate losses and you have a way to realign (for example, rereading the state from the database on reconnection).
The three conditions are not alternatives, they support each other: the third, a way to realign, is what makes the first two acceptable. Without rereading the state from a reliable source, a silent loss becomes a wrong piece of data that nobody notices. If you can't write in one line how your client realigns after a reconnection, yours is not a Pub/Sub case, even when the first two items describe you.
Streams: a log that remembers who read what
The stream keeps even without consumers
A stream is an append-only log. I write two orders when no consumer exists:
$ redis-cli -p 6391 xadd ordini '*' id 1 totale 40
1794122474177-0
$ redis-cli -p 6391 xadd ordini '*' id 2 totale 25
1794122474191-0
$ redis-cli -p 6391 xlen ordini
2
XADD returns the message identifier, which by default is a timestamp in milliseconds plus a counter. The two messages are there and will stay there. The difference from the previous case is all here: the write doesn't depend on who reads.
Rereading the history
Another thing Pub/Sub can't do: read the messages you have already read. With XRANGE you scan the stream by identifier ranges, and - and + mean "from the oldest to the newest":
$ redis-cli -p 6391 xrange ordini - +
1794122474177-0
id
1
totale
40
1794122474191-0
id
2
totale
25
The identifiers are the same two that XADD had returned, in the same order: this is the invariant you start from when you need to understand whether a consumer has read everything. Rebuilding the state after an incident, reprocessing the events with corrected logic, adding a new consumer that starts from the past: all operations that make sense only if the message survives being read. If your case doesn't need them, this capability is weight you carry around.
The consumer group and the trap of $
A consumer group gives several consumers a shared cursor: each message goes to only one of them, and the group keeps track of what it has delivered. It is created with XGROUP CREATE, and the last argument decides where to start. With 0 the group reads the whole history; with $ it starts from the end, that is, it only sees messages arriving after:
$ redis-cli -p 6391 xgroup create ordini nuovo '$'
OK
$ redis-cli -p 6391 xreadgroup group nuovo a count 10 streams ordini '>'
# no messages: redis-cli prints an empty line (null reply)
So a group created with $ after the stream already contains data sees nothing. Same behavior on 7.4.7. It is a common mistake when the group is created in the same deploy as the consumer: the messages written before never arrive. To pick up the history use 0.
The consumer that reads and dies
Now the case that Pub/Sub can't handle. I create a group from 0, consumer a reads a message and "dies" without acknowledging it; consumer b reads the next one and acknowledges it with XACK:
$ redis-cli -p 6391 xgroup create ordini contabilita 0
OK
$ redis-cli -p 6391 xreadgroup group contabilita a count 1 streams ordini '>'
ordini
1794122474177-0
id
1
totale
40
$ redis-cli -p 6391 xreadgroup group contabilita b count 1 streams ordini '>'
ordini
1794122474191-0
id
2
totale
25
$ redis-cli -p 6391 xack ordini contabilita 1794122474191-0
1
$ redis-cli -p 6391 xpending ordini contabilita
1
1794122474177-0
1794122474177-0
a
1
The last call, XPENDING, answers with four things in sequence: the number of messages delivered and not acknowledged (1), the lowest and highest identifier among them (here they coincide) and the consumer that holds them with its count (a, 1). Consumer a's message was not lost: it is in the list of missing acknowledgements, and stays there until someone acknowledges or claims it.
How to read the result of XPENDING
The summary form returns four pieces of information, and it is worth knowing how to recognize them because redis-cli, when it isn't attached to a terminal, prints them one per line without labels:
- The total number of messages delivered and not yet acknowledged (first line).
- The identifier of the oldest among them.
- The identifier of the most recent.
- For each consumer that holds some, its name and its count.
If you add the arguments - + 10 you get the per-message detail: consumer, milliseconds of idle time, number of deliveries. That is the detail you will use in an alert: how many messages have been pending for more than a minute, which consumer holds them.
Claiming the work of whoever died
After an idle time chosen by you, another consumer can take over the messages left pending with XAUTOCLAIM. I wait a second and a half and claim with a threshold of 1000 ms. The command answers with the cursor to resume from (0-0 means it has scanned everything) and with the claimed messages:
$ redis-cli -p 6391 xautoclaim ordini contabilita b 1000 0-0 count 10
0-0
1794122474177-0
id
1
totale
40
$ redis-cli -p 6391 xpending ordini contabilita - + 10
1794122474177-0
b
18
2
$ redis-cli -p 6391 xack ordini contabilita 1794122474177-0
1
$ redis-cli -p 6391 xpending ordini contabilita
0
$ redis-cli -p 6391 xlen ordini
2
The message now belongs to b, idle for 18 ms, and the delivery counter has risen to 2. After the final XACK the list of missing acknowledgements is empty: XPENDING answers 0 and then three empty lines, which are the three null fields of the summary form. That counter is the signal to watch: a message that reaches 5, 6, 7 deliveries is a message nobody manages to process, and you need a dead-letter queue or an alert, not another endless retry. The same sequence on Redis 7.4.7 gives the same answers, with only the idle time different (6 ms): it depends on how much time passes between the two commands, not on the version.
The current Streams documentation also describes XNACK, which gives pending messages back to the group without acknowledging them, so that another consumer receives them at once without waiting for the idle threshold: the command table lists it from 8.8.0. I did not run it, because my instances are 8.0.5 and 7.4.7 and do not have it: I cite it from the page, and if you work on an older version XAUTOCLAIM remains the only way to recover the work.
The stream grows: trimming it is a choice
Acknowledging a message doesn't delete it: after XACK the stream still had the two messages (XLEN = 2 in my tests). Without trimming, memory grows with every event. Trimming is done at write time with MAXLEN:
$ for i in $(seq 1 1000); do echo "XADD log MAXLEN = 100 * n $i"; done | redis-cli -p 6391 --pipe | tail -1
errors: 0, replies: 1000
$ redis-cli -p 6391 xlen log
100
A thousand writes, a hundred messages kept. In the XADD syntax the symbol before the threshold can be = (exact trim, the one I used) or ~ (approximate). The trade-off is explicit: you trim by size and you may throw away a message that a slow consumer hasn't read yet. For this reason it is better to measure how far behind the group is before setting the limit.
Three events, three choices (reasoning, not measurement)
To fix ideas, three imaginary events of a backend, with the answer I would give to the initial question:
- "The user is typing". The message makes sense for a second and is then superseded by the next one. If a listener misses an event, the next one repairs it. Pub/Sub.
- "Generate the invoice for order 42". Losing the event means an order without an invoice, discovered at month's end. Streams with a consumer group, an
XACKafter the invoice has been written and an alert on pending messages. - "Invalidate this cache on all instances". A lost message leaves a stale cache on one instance. It depends on how long the cache lasts: if it has a short TTL, Pub/Sub with that TTL as a safety net; if it lives long, Streams.
Note that the answer doesn't depend on which technology "is better", but on the cost of the error. It is the same reasoning I will apply, later in the series, to the case where Redis itself doesn't answer.
The question about persistence
Saying that the stream "keeps" the message doesn't mean it survives a Redis restart. The Streams documentation specifies that a stream, like every structure, is replicated asynchronously and persisted on AOF and RDB, and that the consumer group state follows the same path. How much is lost then depends on how you configured persistence, which is a topic of its own in the series. Until you have decided that, "Streams doesn't lose messages" is a stronger sentence than the one you can support. The same page specifies two conditions, which I report here without having tried them: for message persistence you need AOF with a strong fsync policy, and asynchronous replication does not guarantee that an XADD or a change in the groups' state reaches the replicas before a failover; the command that reduces the risk is WAIT, but it does not remove it.
In my backend there is neither
I searched the code of the backend and of the ml-service:
$ grep -rn "opsForStream\|XADD\|RedisMessageListenerContainer" backend/src/main ml-service/app
$ echo "exit=$?"
exit=1
No matches (a grep exit of 1 means "nothing found"). The backend has four files that use SimpMessagingTemplate, that is STOMP messaging over WebSocket, with an in-memory broker (enableSimpleBroker in WebSocketConfig.java, line 64), not Redis. So this article doesn't tell an experience of the site: it is a comparison between two tools that you can measure, and I kept it in the series because Pub/Sub has been part of the series' promise from the start. If in the future the site adopts Streams, this section changes.
How to choose
The three questions I ask myself, in order:
- Can I lose the message if the reader is down? Yes: Pub/Sub is enough. No: Streams.
- Must a message be processed by only one among many consumers? Yes: consumer group on a stream. Pub/Sub delivers to everyone.
- Do I need to be able to reread or reprocess? Yes: only a stream allows it.
The order of the questions is the one in which they eliminate options: the first rules out Pub/Sub if the loss costs you, and at that point the other two only serve to decide how to use Streams. If instead you answer yes to the first and the loss costs you nothing, you don't need to go on: Pub/Sub is the simplest choice that holds up for your case, and simplicity matters more than power you don't use.
What a Streams consumer must do to be reliable
- Acknowledge with
XACKonly after finishing the work, never before. - Be able to receive the same message twice without harm, because delivery can repeat after a recovery with
XAUTOCLAIM: you need an idempotent processor. - Check
XPENDINGperiodically and put a limit on the number of deliveries. - Have a stream trimming policy (
MAXLEN) decided based on how far behind the group is. - Create the group with
0if it must see the history, with$only if you want to ignore it.
The five rules protect against the same scenario: the message delivered twice or left pending. Acknowledging after the work and idempotency are the pair that makes repetition harmless, XPENDING and the delivery limit prevent a message that can't be processed from circling forever, while stream trimming and the group's starting point concern space and history. The latter are the ones that get forgotten while the stream is still small.
The trade-off of Streams
I preferred Streams to Pub/Sub when the event can't be lost, but I paid for it in three ways: I have to write the acknowledgement and recovery logic (XACK, XAUTOCLAIM), I have to decide when to trim the stream and I have to monitor pending messages. For an update that the client can recompute, that complexity doesn't pay off. And if you find yourself missing a real broker (ordering across partitions, time-based retention, replay per group), evaluate that tool before building one on top of Streams.
When not to use them
- For a lock or an operation that must be atomic: you need a script, as in Lua in Redis.
- For data that must survive with certainty: first evaluate what your persistence configuration guarantees.
- To notify a browser: in this backend it is done by STOMP over WebSocket with Spring's in-memory broker, without Redis.
The three exclusions have the same root: Streams is a message log, not an answer to every coordination problem. The lock asks for atomicity, and it is the topic of the Lua script; durability depends on the persistence you configured and not on the structure you choose; the notification to the browser already has a channel on the site, STOMP over WebSocket, which doesn't go through Redis. If you use Streams for one of these, you are looking for another tool.
What you can do now
You can prove with three commands whether a channel loses messages (PUBLISH answering 0), you can create a group that doesn't skip the history (0 and not $), and you can recognize and recover the work of a dead consumer with XPENDING and XAUTOCLAIM, keeping an eye on the delivery counter. To understand how Redis chooses the representation of the data you are putting in the stream, start again from data structures and costs; if the event must also be non-duplicated, the idempotency question is in the article on consumer idempotency. To see what the backend does when Redis itself does not answer, there is the article on fail-open and fail-closed.
Do you need to decide the event mechanism of your backend? Book a call →
Frequently asked questions
Does Redis Pub/Sub lose messages if the subscriber is disconnected?
Yes. The Redis documentation describes at-most-once delivery: the message arrives once if it arrives. In a test on Redis 8.0.5 and 7.4.7, PUBLISH answered 0 with no listeners and the message was not delivered on reconnection.
With Redis Streams, is an unacknowledged message lost?
No: it stays in the group's pending list, visible with XPENDING. Another consumer can claim it with XAUTOCLAIM after an idle time; the delivery counter goes up and must be watched.
Why does a group created with $ not read the messages already there?
Because $ means 'start from the last message': the group only sees those written afterwards. To read the history too, create the group with 0.
Sources
- Redis, Pub/Sub - the at-most-once semantics of delivery, consulted on 2026-11-13.
- Redis, Streams - consumer groups, list of pending messages, persistence with AOF and fsync, asynchronous replication and XNACK since 8.8.0, consulted on 2026-11-13.
- Redis, PUBLISH - the return value is the number of clients that received the message, consulted on 2026-11-08.
- Redis, XADD - syntax of
MAXLENwith=or~, consulted on 2026-11-08. - Redis, XAUTOCLAIM - syntax with minimum idle time, available since 6.2.0, consulted on 2026-11-13.
- Measurements: the commands in the blocks above, run on 2026-11-08 on Redis 8.0.5 (native) and 7.4.7 (
redis/redis-stack-server:latest), and thegrepon the code ofbackend/src/mainandml-service/app.
The sources are the official Redis pages, with the date on which I read them: they serve to check the delivery guarantees and the syntax, not to understand the mechanism, which the article shows with the commands. The last item gathers the measurements, that is the code blocks above, which you can redo on your own instance; if a number doesn't match, trust your output and the version you are using.

Commenti
Caricamento commenti...
Sign in to comment