Redis gives you two ways to move messages between services: Pub/Sub and Streams. Both look like they solve the same problem. They don't. And picking the wrong one at 3 AM when your event pipeline is on fire is a mistake you only make once.
The Core Problem
Pub/Sub and Streams solve different problems that sound identical on paper. Both let you send messages between services. Both decouple producers from consumers. But the moment your system hits real traffic, the differences become painfully obvious.
Here's the short version: Pub/Sub is a broadcast. You publish, subscribers get it, and then it's gone. Forever. No replay, no history, no "hey, I was offline for 30 seconds, what did I miss?"
Streams is the opposite. Every message lands in a log that sticks around until you explicitly delete it. Consumers can read at their own pace, re-read old messages, and pick up exactly where they left off after a crash.
The question isn't which one is better. It's which one fits what you're building.
How Pub/Sub Actually Works
When you call PUBLISH channel message, Redis takes that message and hands it to every client currently subscribed to that channel. There's no queue. There's no buffer. If a subscriber is disconnected at that exact moment, the message evaporates.
# Producer
PUBLISH user-events "user:1234:login"
# Consumer
SUBSCRIBE user-eventsThat's it. No consumer groups, no offsets, no acknowledgments. The simplicity is the feature.
Redis handles this with an in-memory fan-out. Each subscriber has a small output buffer (configurable via client-output-buffer-limit pubsub). If a subscriber can't keep up and the buffer fills, Redis disconnects it. That's your backpressure mechanism, and it's brutal.
When Pub/Sub Works Well
Real-time dashboards where stale data is useless. If you miss a metrics update at 10:03:42, the one at 10:03:43 replaces it anyway. Chat typing indicators. Live score feeds. Cache invalidation signals where you just need to say "this key changed, go refresh."
These all share a trait: the messages are transient by nature. Losing one doesn't break anything.
The Pitfalls Nobody Mentions
Subscriber scale kills performance. Redis Pub/Sub uses fan-out on publish. Ten subscribers? Fine. A thousand? You'll notice the latency. Ten thousand? Publish commands start blocking because Redis is copying the same message into thousands of output buffers, single-threaded.
No message ordering guarantee across subscribers. Each subscriber gets the message independently. Under load, subscriber A might receive message 5 before subscriber B, even though message 4 is still in B's buffer. This rarely matters in practice for fire-and-forget patterns, but it's worth knowing.
The buffer overflow cliff. There's no graceful degradation. A slow subscriber doesn't get a "you're falling behind" warning. It gets disconnected. Your application needs to handle reconnection and accept that whatever happened during the disconnect is lost.
Pattern subscriptions are expensive. PSUBSCRIBE user:* forces Redis to match every publish against every pattern subscription. With lots of patterns and lots of channels, this gets slow fast. Test it under load before you commit.
How Streams Work
Streams were added in Redis 5.0 specifically because Pub/Sub's amnesia was a problem for real workloads. A Stream is an append-only log stored in Redis. Each entry gets an auto-generated ID based on the timestamp and a sequence number.
# Producer
XADD mystream * user_id 1234 action login
# Consumer (simple read)
XREAD COUNT 10 STREAMS mystream 0
# Consumer group
XGROUP CREATE mystream mygroup $ MKSTREAM
XREADGROUP GROUP mygroup consumer-1 COUNT 1 STREAMS mystream >The * in XADD tells Redis to generate the ID. The $ in XGROUP CREATE means "start from new messages only." The > in XREADGROUP means "give me messages I haven't seen yet."
These little symbols do a lot of heavy lifting.
Consumer Groups: The Real Power
Consumer groups are what make Streams production-grade. Multiple consumers in the same group each get a disjoint subset of messages. No duplicates within a group, no coordination needed.
When a consumer reads a message, it gets pending entries list (PEL) tracking. The message sits in a "pending" state until the consumer calls XACK. If the consumer crashes without acknowledging, another consumer can claim those messages with XAUTOCLAIM or XCLAIM.
This is exactly the pattern you need for task queues where you can't afford to lose work. For simpler coordination needs, many teams find that Postgres transactions already provide the primitives they need before reaching for a second system.
# Claim messages idle for 60 seconds
XAUTOCLAIM mystream mygroup consumer-2 60000 0-0Streams Pitfalls
Memory. Streams store everything. A high-throughput stream without MAXLEN or MINID trimming will eat your RAM.
# Trim to roughly 100,000 entries
XADD mystream MAXLEN ~ 100000 * key valueThe ~ lets Redis trim in chunks for efficiency. Without it, every XADD does an exact-count trim, which costs more CPU.
The "pending but never acked" zombie problem. If your consumer reads messages but crashes before acknowledging, those messages sit in the PEL forever. You need a reaper process that runs XAUTOCLAIM periodically. Forget this and your consumer group slowly fills with ghost entries.
Backpressure isn't automatic. Unlike Pub/Sub's hard disconnect, Streams let consumers fall as far behind as RAM allows. This feels like a feature until a consumer gets stuck and your stream grows to 10GB. Monitor stream length with XLEN and set alerts.
Cluster gotcha. In Redis Cluster, a Stream lives on a single shard. All consumer group operations hit that one node. For hot streams, this creates a hotspot. You can shard across multiple streams with a round-robin or hash-based producer, but then ordering per-key gets complicated.
Decision Framework
Ask yourself these questions. Document the reasoning behind whichever path you take. Architecture decision records prevent the same Pub/Sub vs. Streams debate from resurfacing every quarter.
Do subscribers need every message, even if they're briefly offline? Streams. Pub/Sub will lose those messages.
Are you building a task queue where each message gets processed by one worker? Streams with consumer groups. Pub/Sub can't do this.
Is the message content "fire and forget" with no retry semantics? Pub/Sub. It's simpler and has lower latency.
How many subscribers? Under 100, Pub/Sub is fine. Over 1000, think twice about Pub/Sub fan-out costs. At that scale you'll likely need distributed rate limiting to keep things stable.
Do you need message history for debugging or replay? Streams. Pub/Sub has zero history.
Is this a notification that replaces previous state (cache invalidation, latest metrics)? Pub/Sub. Old values don't matter.
Production Patterns That Hold Up
The Hybrid Approach
Most systems I've seen work well with both. Use Pub/Sub for ephemeral signals (typing indicators, presence updates, cache invalidation). Use Streams for events that represent actual business logic (order placed, payment received, user registered).
They don't compete. They complement.
Streams as a Buffer Layer
One pattern that works well: producers push to a Stream, and a separate consumer reads from the stream and fans out via Pub/Sub to connected WebSocket clients. You get the persistence of Streams plus the real-time fan-out of Pub/Sub. The Stream acts as a buffer so if the fan-out consumer crashes and restarts, it picks up where it left off.
# Order service
XADD orders * order_id 9876 action created
# Fan-out consumer
XREADGROUP GROUP fanout worker-1 COUNT 10 STREAMS orders >
# For each message:
PUBLISH ws:orders "order 9876 created"
XACK orders fanout <message-id>Dead Letter Handling
Streams don't have built-in dead letter queues. Build one manually: after N retries with XAUTOCLAIM, XADD the message to a separate dead_letters stream. Your monitoring picks it up from there. It's boring but it works.
Size Your Consumer Buffers
For Pub/Sub, set client-output-buffer-limit pubsub based on your subscriber count and message rate. The default 32MB hard / 8MB soft with 60-second window is often too generous for high-subscriber setups. Tighten it to get faster failure detection.
For Streams, set alerts on XLEN at maybe 500K and 1M entries. If you're approaching limits, either trim more aggressively or investigate why consumers are falling behind.
The Boring Truth
Neither Pub/Sub nor Streams is going to solve your architecture problems if you don't understand what each one guarantees. Pub/Sub guarantees nothing. That's fine for ephemeral data. Streams guarantee persistence and at-least-once delivery within a consumer group. That's what you need for anything that matters.
Pick based on whether losing a message is acceptable. That single question will point you to the right tool almost every time.



