Message Queue vs Pub/Sub: What's the Difference?

Written by: Bagus Facsi Aginsa
Published at: 10 Sep 2022


If you are learning about microservices, you must have heard the term Pub/Sub or Message Queue. Pub/Sub and Message Queue are often used for alternative methods of communication between microservices other than HTTP.

When I first learned about Pub/Sub and Message Queues, I was very confused about the difference between the two. So I sat down with Redis to see how they actually behave, and in this article I’ll walk you through the difference hands-on with redis-cli.

I hope you guys will understand better the difference between Pub/Sub and Message Queue.


The Short Answer: Pub/Sub vs Message Queue

If you only want the quick answer: Pub/Sub broadcasts every message to all subscribers and forgets it immediately, while a message queue hands each message to exactly one consumer and holds onto it until someone takes it. Pub/Sub is great for fan-out events where it’s fine to miss a message; a message queue is for work that has to be done once and shouldn’t get lost. Here’s the difference at a glance:

  Pub/Sub Message Queue
Delivery Broadcast to all subscribers Delivered to one consumer
If no one is listening Message is dropped Message is kept until consumed
Guarantee No delivery guarantee Guaranteed someone gets it
Typical use Events, notifications, fan-out Background jobs, task processing

The rest of this article walks through why these differences exist, with hands-on redis-cli commands so you can watch it happen yourself.

A quick note on terminology: “message queue” in the wider industry usually means a dedicated broker like RabbitMQ, Apache Kafka, or AWS SQS. This article doesn’t compare those products — it uses Redis to demonstrate the underlying Pub/Sub and queue concepts, because Redis can do both with very little setup. The ideas you’ll see here carry over to those systems.


Prerequisites

To follow along with the hands-on parts of this article you need:

  • Ubuntu 20.04, 22.04, or 24.04 (any Linux or macOS works too, the commands are the same)
  • Redis running locally, either installed directly or via Docker. The quickest way is Docker: docker run -d --name redis -p 6379:6379 redis:latest
  • The redis-cli client, to run the commands by hand. It ships with Redis, or you can use the one inside the container: docker exec -it redis redis-cli

That’s all you need — every example below runs with nothing but Redis and redis-cli. The examples use two terminal windows in a few places (one publisher, one consumer), so keep a second terminal handy.


Pub/Sub and Message Queue Overview

Well, I don’t know about you, but the definition does not matter that much to me as a developer. What I know is Pub/Sub and Message Queue is some kind of method to send a message/event asynchronously from 1 service to another service.

So, how is that different from HTTP? Well, HTTP can send messages directly from point A to B, while Pub/Sub and Message Queue need some intermediary. You can look at the diagram below:

This is HTTP communication

 ___                  ___
|   | ---- msg ----> |   |
| A |                | B |
|___| <--- ack ----- |___|

In HTTP communication, A directly sends the message to B, and Bsends an acknowledge message back, so A knows for sure that Breceives the message.

This is pub/sub communication

 ___               ___               ___
|   | --- msg --> |   |             |   |
| A | <-- ack --- | X | --- msg --> | B |
|___|             |___|             |___|

In Pub/Sub, Apublishes a message to X (a Pub/Sub service) in a specific channel, and then X will broadcast the message to anyone who subscribes to the channel. So if there is another service like C, D, E that subscribes to the channel, they will also get the same message as B. If no service subscribes to the channel, then X will drop the message.

In this communication, A doesn’t know who got the message and there is no guarantee that the message will be sent to other services.

This is Message Queue communication

 ___               ___               ___
|   | --- msg --> |   |             |   |
| A | <-- ack --- | X | <-- req --- | B |
|___|             |___| --- msg --> |___|

In Message Queue, Aproduces a message to X, and then X will wait for another service to request and consume the message. In this case, Bsends a request to X to see if there is a message. The X will then give the message to B. If no one requests the message, then the X will keep the message until someone requests it.

In this communication, A doesn’t know who got the message, but it is guaranteed that the message will be sent to other services.


Try It Yourself with redis-cli

Let’s see both patterns with nothing but redis-cli. This is the fastest way to feel the difference, no code to write, just two terminals.

Pub/Sub with SUBSCRIBE and PUBLISH

Open terminal 1 and subscribe to a channel called pizza:

redis-cli SUBSCRIBE pizza

redis-cli now blocks and waits for messages. In terminal 2, publish a message to the same channel:

redis-cli PUBLISH pizza "I want a pizza 1"

PUBLISH returns the number of subscribers that received the message:

(integer) 1

And over in terminal 1, the message shows up immediately:

1) "message"
2) "pizza"
3) "I want a pizza 1"

Now open a terminal 3 and run the same SUBSCRIBE pizza. Publish again from terminal 2, and you’ll see the message arrive in both terminal 1 and terminal 3 — that’s the broadcast behavior. Here’s the key experiment: close both subscribers (Ctrl-C), publish a message while no one is listening, then subscribe again. PUBLISH returns (integer) 0 and the new subscriber never sees that message. Pub/Sub dropped it because no one was there to receive it.

A Simple Queue with LPUSH and BRPOP

A List gives us queue behavior. The producer pushes onto one end with LPUSH, and the consumer blocks waiting on the other end with BRPOP (blocking right pop). In terminal 1, push three messages into a list called pizza_queue:

redis-cli LPUSH pizza_queue "pizza 1" "pizza 2" "pizza 3"
(integer) 3

Notice the messages are sitting in Redis already — unlike Pub/Sub, nobody had to be listening. Now consume them in terminal 2:

redis-cli BRPOP pizza_queue 0
1) "pizza_queue"
2) "pizza 1"

BRPOP pops one message and returns. Run it again and you get pizza 2, then pizza 3. The 0 means “block forever until a message arrives” — run BRPOP once more and it just waits. Push a new message from terminal 1 and the waiting BRPOP returns instantly.

The important part: each message is handed to exactly one consumer and then removed. If you start a second BRPOP in terminal 3, the two consumers split the messages between them — Redis never gives the same list item to both. That’s the queue behavior the diagrams described, in two commands.

But notice what’s missing: once BRPOP returns a message, it’s gone from Redis. If the consumer crashes before finishing the work, there’s no record that the message was ever taken. That gap is exactly what Redis Streams fixes, which we’ll get to below.


A Better Queue: Redis Streams and Consumer Groups

The simple queue above is just a Redis List (push a message on one side, pop it from the other). That works, and it’s the classic way to build a quick queue, but it’s pretty bare-bones. Once a message is popped, it’s gone. If your consumer crashes right after taking a message but before finishing the work, that message is lost. There’s also no built-in way to split the work across a group of consumers while making sure each message is handled by only one of them — you have to build all of that yourself.

Redis later added a data type built specifically for this: the Stream. A Stream is an append-only log of messages. Instead of removing a message when you read it, the message stays in the log, and each consumer keeps track of where it has read up to. That alone gives you two things a List queue doesn’t:

  • Persistence and replay. Because messages aren’t deleted on read, you can go back and re-read history, and new consumers can start from the beginning of the log instead of only seeing messages produced after they connected.
  • A timeline. Every message gets an ID based on its time of arrival, so the ordering is explicit.

On top of Streams, Redis adds consumer groups, which is where it really starts to feel like a proper message queue:

  • Work distribution. Several consumers can join the same group, and Redis splits the incoming messages across them so each message goes to only one consumer in the group — exactly the “one consumer gets it” behavior we saw with the List queue, but managed for you.
  • Acknowledgements. When a consumer reads a message, Redis holds it in a “pending” state for that consumer until the consumer explicitly acknowledges that it finished processing. If the consumer never acknowledges (say it crashed), the message stays pending and can be reassigned to another consumer instead of silently disappearing.

Streams in redis-cli

Let’s see it. First, add a few messages to a stream called pizza_stream. The * tells Redis to auto-generate the message ID from the current time:

redis-cli XADD pizza_stream '*' msg "pizza 1"
redis-cli XADD pizza_stream '*' msg "pizza 2"

Each XADD returns the generated ID, a timestamp plus a sequence number:

"1717200000000-0"
"1717200000050-0"

Now create a consumer group called kitchen. The 0 means the group starts reading from the very beginning of the stream (use $ instead if you only want messages added after the group is created):

redis-cli XGROUP CREATE pizza_stream kitchen 0
OK

A consumer named chef1 reads from the group. The > is special: it means “give me messages that have never been delivered to any consumer in this group”:

redis-cli XREADGROUP GROUP kitchen chef1 COUNT 1 STREAMS pizza_stream '>'
1) 1) "pizza_stream"
   2) 1) 1) "1717200000000-0"
         2) 1) "msg"
            2) "pizza 1"

chef1 got pizza 1. If a second consumer chef2 reads with the same command, it gets pizza 2, not pizza 1 again — the group distributes each message to only one consumer, just like our List queue, but managed for us.

Here’s the part a List can’t do. That message isn’t gone, it’s now pending for chef1 until it’s acknowledged. Check the pending list:

redis-cli XPENDING pizza_stream kitchen
1) (integer) 1
2) "1717200000000-0"
3) "1717200000000-0"
4) 1) 1) "chef1"
      2) "1"

There’s one unacknowledged message held against chef1. If chef1 crashes here, the message is not lost, another consumer can claim it and finish the job. Once the work is actually done, the consumer acknowledges it:

redis-cli XACK pizza_stream kitchen 1717200000000-0
(integer) 1

Now XPENDING shows zero pending messages. That cycle, read → process → acknowledge, with anything unacknowledged left recoverable, is the durability a plain List queue simply doesn’t have.

If you’re reaching for Redis to build something queue-like today, Streams with consumer groups is usually the better starting point than a raw List.


When to Use Each

We’ve now seen three distinct patterns. Here’s how I decide between them:

  Pub/Sub List queue Streams + consumer groups
Delivery Broadcast to all subscribers One consumer per message One consumer per message
Persistence None — dropped if no subscriber Held until popped, then deleted Held and retained after reading
Replay history No No Yes
Acknowledgements No No Yes
Recover after a crash No No — message already removed Yes — stays pending until acked
Setup effort Lowest Low Moderate
  • Reach for Pub/Sub when you want to broadcast an event to everyone and it’s fine to miss it if no one is listening — live notifications, cache invalidation signals, fan-out to multiple independent subscribers.
  • Reach for a List queue when you want each message handled exactly once, the work is short-lived, and you want the absolute simplest thing that works.
  • Reach for Streams with consumer groups when that “handled once” work actually matters — when losing a message on a crash is unacceptable, or you want multiple workers sharing the load with acknowledgements and replay. For most queue-like work in Redis today, this is my default.

Common Mistakes and Troubleshooting

Expecting Pub/Sub to deliver messages published while you were offline. This is the most common surprise. Pub/Sub is fire-and-forget: if no client is subscribed at the moment of PUBLISH, the message is gone. If you need a subscriber to catch up on missed messages, you want a Stream, not Pub/Sub.

Using a List queue for work that must not be lost. With BRPOP, the message is removed from Redis the instant it’s read. If your worker crashes mid-processing, there is no trace that the message ever existed. For anything where losing a job is unacceptable, use Streams so the message stays pending until you explicitly XACK it.

Reading a stream before the consumer group exists. XREADGROUP fails if the group hasn’t been created yet. Run XGROUP CREATE first. A handy trick is XGROUP CREATE <stream> <group> 0 MKSTREAM, which creates the stream too if it doesn’t exist yet, so app startup doesn’t depend on ordering.

Never acknowledging messages. If your consumers read with XREADGROUP but never call XACK, every message piles up in the pending list forever. Over time that’s a memory leak and you lose the ability to tell what’s actually been processed. Always acknowledge after the work succeeds.

Letting a stream grow without bound. Unlike a List, a Stream keeps messages after they’re read, so it grows indefinitely unless you cap it. Add a maximum length when producing (the MAXLEN option on XADD) so old, already-processed entries get trimmed instead of filling up memory.


Best Practices

Pick the pattern by durability need, not by habit. Pub/Sub for disposable broadcasts, Streams for work that has to survive a crash. A List queue is fine for genuinely throwaway tasks, but if you’re unsure, Streams is the safer default.

Always acknowledge processed messages. Treat XREADGROUP and XACK as a pair. Read, do the work, then acknowledge — only after the work truly succeeded.

Cap your streams. Use MAXLEN (or periodic trimming) so a Stream doesn’t grow forever. Decide how much history you actually need to replay and trim beyond that.

Don’t treat Redis as a guaranteed-durable broker out of the box. Redis persistence settings affect how much you can lose on a hard crash. If durability matters, read up on how Redis saves data — see my post on comparing Redis persistence options — and consider running Redis in a highly available setup with Sentinel.

Know when you’ve outgrown Redis. Redis is a great way to learn these patterns and handles plenty of real workloads. But if you need strong ordering guarantees across partitions, long-term retention, or very high throughput with replay, a purpose-built log like Kafka is the better tool — see getting started with Apache Kafka.


Conclusion

We started with a simple question, what’s the difference between Pub/Sub and a message queue, and ended up with three patterns: Pub/Sub broadcasts and forgets, a List queue hands each message to one consumer and deletes it, and Streams with consumer groups give you that same one-consumer delivery plus persistence, replay, and acknowledgements.

One last thing worth pinning down is the vocabulary, because the two patterns use different names for the same roles and mixing them up trips up a lot of people:

  Pub/Sub Message Queue
The one who sends the message Publisher Producer
The one who receives the message Subscriber Consumer

That’s it, now you know the practical differences and you’ve seen each one running in redis-cli. Pick the pattern by what your messages can afford to lose, and you’ll choose correctly.

If you want to go deeper on Redis itself, see comparing Redis persistence options for how durable your data really is, and setting up high availability with Sentinel for keeping it online. And when you outgrow Redis for messaging, getting started with Apache Kafka is the natural next step.