Redis Pub/Sub and Event Fan-Out Patterns
A staff-engineer guide to Redis Pub/Sub mechanics, real-time fan-out design, and where it falls short of a real message broker like Kafka.
Redis Pub/Sub and Event Fan-Out
Redis Pub/Sub is the simplest possible messaging primitive: publish a message to a channel, and every currently-subscribed client receives it, immediately, with zero persistence. That simplicity is both its appeal and its danger — it's perfect for a specific class of problems (real-time fan-out where losing a message occasionally is fine) and actively wrong for anything that needs durability, replay, or delivery guarantees. This guide covers the mechanics, the legitimate use cases, and precisely where you need to stop using Pub/Sub and reach for a real broker.
1. Pub/Sub Mechanics
Redis Pub/Sub decouples publishers from subscribers through named channels. A publisher doesn't know or care who's listening; subscribers don't know or care who published. Redis simply fans out each message to every client currently subscribed to that channel at the moment of publish.
Core commands
# Terminal 1 — subscribe to a channel
SUBSCRIBE orders:updates
# Terminal 2 — publish a message
PUBLISH orders:updates '{"orderId":"5001","status":"SHIPPED"}'
# → Terminal 1 immediately receives the message
# Pattern subscription — glob-style matching across channels
PSUBSCRIBE orders:*
PUBLISH orders:updates '...' # matches
PUBLISH orders:cancellations '...' # also matches
# Introspection
PUBSUB CHANNELS orders:* # active channels matching a pattern
PUBSUB NUMSUB orders:updates # subscriber count for a channelIf nobody is subscribed when you publish, the message is gone. There's no queue, no buffer, no persistence — Pub/Sub delivers to whoever happens to be connected at that exact instant. This is the single most important thing to internalize before using it for anything.
2. Pub/Sub with Spring Boot
Spring Data Redis wraps Pub/Sub with a MessageListener abstraction and a container that manages the subscription lifecycle for you.
@Configuration
public class RedisPubSubConfig {
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer(
RedisConnectionFactory connectionFactory,
OrderUpdateListener orderUpdateListener) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.addMessageListener(orderUpdateListener, new ChannelTopic("orders:updates"));
return container;
}
}@Component
public class OrderUpdateListener implements MessageListener {
private final SimpMessagingTemplate websocketTemplate;
private final ObjectMapper objectMapper;
@Override
public void onMessage(Message message, byte[] pattern) {
try {
OrderUpdateEvent event = objectMapper.readValue(
message.getBody(), OrderUpdateEvent.class);
// Fan out to any connected WebSocket clients watching this order
websocketTemplate.convertAndSend(
"/topic/orders/" + event.orderId(), event);
} catch (IOException e) {
log.error("Failed to deserialize order update event", e);
// No retry, no dead-letter — the message is already gone from Redis
}
}
}@Service
public class OrderEventPublisher {
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
public void publishOrderUpdate(String orderId, OrderStatus status) {
try {
String payload = objectMapper.writeValueAsString(
new OrderUpdateEvent(orderId, status, Instant.now()));
redisTemplate.convertAndSend("orders:updates", payload);
} catch (JsonProcessingException e) {
throw new EventPublishException("Failed to serialize order update", e);
}
}
}onMessage runs on the listener container's thread pool, not the publisher's thread. A slow or blocking listener can back up message processing for that container. Keep listeners fast — offload heavy work (DB writes, external API calls) to an async executor rather than doing it inline in onMessage.
3. Legitimate Use Cases
Pub/Sub earns its place when the workload genuinely tolerates "fire and forget, best effort, right now or not at all."
| Use case | Why Pub/Sub fits |
|---|---|
| Live WebSocket fan-out | Browser is either connected right now or it isn't — no benefit to persisting a message for a disconnected client |
| Cache invalidation across instances | Each app instance subscribes and evicts a local (L1) cache entry on notification; a missed message just means a slightly longer local-cache staleness window |
| Presence / "user is typing" indicators | Inherently ephemeral — stale presence data is worse than a missed update |
| Multi-instance config hot-reload | Notify all instances to re-read config; each instance can also poll periodically as a fallback |
| Real-time dashboards / live metrics ticks | Next tick supersedes a missed one; no value in replaying old ticks |
Local cache invalidation fan-out example
A common production pattern: each service instance keeps a small in-process (Caffeine/Guava) L1 cache in front of Redis for extremely hot keys, and uses Pub/Sub purely to tell sibling instances "invalidate your local copy."
@Component
public class LocalCacheInvalidationListener implements MessageListener {
private final Cache<String, Object> localCache; // Caffeine
@Override
public void onMessage(Message message, byte[] pattern) {
String key = new String(message.getBody(), StandardCharsets.UTF_8);
localCache.invalidate(key);
}
}This is Pub/Sub's sweet spot: a low-stakes, self-healing use case. If an instance misses an invalidation message (was restarting, network blip), the worst outcome is that one instance serves a slightly stale local cache entry until its own TTL expires. No business-critical data is lost.
4. Where Pub/Sub Breaks Down
The moment "we can't afford to lose this message" enters the conversation, Redis Pub/Sub is the wrong tool.
What Redis Pub/Sub does NOT give you
| Guarantee | Redis Pub/Sub | What you actually need instead |
|---|---|---|
| Message persistence | None — undelivered messages vanish instantly | Redis Streams (bounded log) or Kafka (durable log) |
| Delivery to offline consumers | None — must be subscribed at publish time | Streams (XREAD from a position) or a queue-based broker |
| At-least-once delivery / acknowledgment | None — fire and forget, no ACK concept | Streams consumer groups (XACK) or Kafka/RabbitMQ |
| Consumer groups (load-balanced fan-out) | None — every subscriber gets every message | Streams consumer groups, Kafka consumer groups |
| Replay / historical read | None — no concept of position or offset | Kafka (retained log), Streams (bounded log with IDs) |
| Ordering guarantees across partitions/shards | Best-effort within one Redis node only | Kafka partitions with keyed ordering |
| Backpressure handling | None — slow subscriber gets disconnected (client-output-buffer-limit) | Broker-side consumer offset tracking, pull-based consumption |
A slow subscriber in Redis Pub/Sub gets forcibly disconnected, not throttled. Redis enforces client-output-buffer-limit pubsub <hard> <soft> <soft-seconds> — if a subscriber's outbound buffer grows past the limit (because it's not reading fast enough), Redis kills that client's connection outright. There's no backpressure mechanism; the alternative to disconnection would be Redis buffering unboundedly and risking its own memory, which is worse.
The disconnected-subscriber gap
This is the failure mode that catches teams off guard in production: a subscriber process restarts (deploy, crash, autoscaling event) and silently misses every message published during that window, with no error, no gap indicator, nothing.
Every deploy of a Pub/Sub subscriber is a guaranteed message-loss window, however small. If your fan-out use case can tolerate that (WebSocket notifications, cache invalidation), fine. If it can't (billing events, order state transitions that other systems depend on), you need durable delivery — Redis Streams or a dedicated broker.
5. Redis Streams: The Middle Ground
If you want to stay in the Redis ecosystem but need durability and replay, Redis Streams (XADD/XREAD/XREADGROUP, added in Redis 5.0) is a genuinely different data structure — an append-only log with IDs, consumer groups, and acknowledgment — not the same thing as Pub/Sub.
# Append to a stream (persisted, unlike Pub/Sub)
XADD orders:stream '*' orderId 5001 status SHIPPED
# Consumer group — multiple consumers share the work, each message delivered once per group
XGROUP CREATE orders:stream notification-workers '$' MKSTREAM
XREADGROUP GROUP notification-workers worker-1 COUNT 10 STREAMS orders:stream '>'
XACK orders:stream notification-workers 1699999999-0@Component
public class OrderStreamConsumer {
private final StreamMessageListenerContainer<String, MapRecord<String, String, String>> container;
@PostConstruct
public void start() {
StreamListener<String, MapRecord<String, String, String>> listener = message -> {
processOrderEvent(message);
// ack after successful processing — durable, replayable if this crashes first
};
container.receive(
Consumer.from("notification-workers", "worker-1"),
StreamOffset.create("orders:stream", ReadOffset.lastConsumed()),
listener
);
container.start();
}
}Pub/Sub vs Streams vs Kafka
| Aspect | Redis Pub/Sub | Redis Streams | Kafka |
|---|---|---|---|
| Persistence | None | Yes (bounded log, MAXLEN trimming) | Yes (configurable retention, disk-backed log) |
| Delivery to offline consumers | No | Yes (read from any position) | Yes (read from any offset) |
| Consumer groups | No | Yes | Yes |
| Acknowledgment / redelivery | No | Yes (XACK, pending entries list) | Yes (offset commits) |
| Ordering guarantee | Best-effort, single node | Per-stream ordering | Per-partition ordering, high throughput |
| Horizontal scale for huge volume | N/A | Limited (single Redis node bottleneck for one stream) | Built for this — partitioning across brokers |
| Operational complexity | Minimal | Low-moderate | Higher — dedicated cluster, ZooKeeper/KRaft |
| Best for | Ephemeral real-time fan-out | Durable but modest-throughput event log within a Redis-centric stack | High-throughput, durable, replayable event backbone across many services |
Streams are not a drop-in Kafka replacement — they live in one Redis node's memory (plus whatever persistence you've configured), so throughput and retention are bounded by that instance's resources in a way Kafka's distributed partitioned log isn't. For genuinely high-volume, cross-team, long-retention event streaming, Kafka remains the right default; reach for Streams when you want "a bit more than Pub/Sub" without standing up a whole new system.
6. Fan-Out Workflow Design
When designing a fan-out workflow, the decision isn't "Pub/Sub or Kafka" in the abstract — it's about matching the durability requirement of each specific event type to the right tool, sometimes within the same system.
A common, pragmatic pattern: publish the canonical event to Kafka (durable, source of truth for every downstream system that must not miss it), and have one dedicated consumer of that Kafka topic re-publish a lightweight "something changed" ping to Redis Pub/Sub purely to wake up WebSocket gateways for real-time UI updates. This gets you durability where it matters and low-latency fan-out where it doesn't, without forcing every consumer through the same delivery semantics.
@Component
public class OrderEventBridge {
private final StringRedisTemplate redisTemplate;
@KafkaListener(topics = "orders.status-changed", groupId = "realtime-bridge")
public void onOrderStatusChanged(OrderStatusChangedEvent event) {
// Kafka already guaranteed durable delivery to every real consumer.
// This just wakes up connected WebSocket clients — best-effort is fine here.
redisTemplate.convertAndSend(
"orders:updates",
toLightweightPayload(event)
);
}
}7. Sharded Pub/Sub in Redis Cluster
If you're running Redis Cluster, standard PUBLISH/SUBSCRIBE messages are broadcast to every node in the cluster, regardless of which node the channel "belongs to" — this can become a real bottleneck at high message volume across a large cluster. Redis 7.0 introduced Sharded Pub/Sub (SPUBLISH/SSUBSCRIBE) to address this.
# Cluster-aware — message only propagates to nodes owning the relevant hash slot
SSUBSCRIBE orders:updates
SPUBLISH orders:updates '{"orderId":"5001"}'Regular PUBLISH in a clustered deployment means every message is broadcast cluster-wide, which scales poorly as you add nodes. If you're running Redis Cluster (not just a single primary + replicas) and pushing meaningful Pub/Sub volume, migrate to SPUBLISH/SSUBSCRIBE so messages stay confined to the relevant shard.
Key takeaways
- Redis Pub/Sub has zero persistence — a message published with no active subscriber is gone forever, and there's no way to detect that it happened.
- Every subscriber restart is a message-loss window. This is fine for ephemeral data (live cache invalidation, presence, WebSocket ticks) and unacceptable for anything another system depends on for correctness.
- Slow subscribers get disconnected, not throttled — Redis enforces
client-output-buffer-limitfor Pub/Sub clients rather than buffering indefinitely. - Redis Streams is a genuinely different structure from Pub/Sub — durable, replayable, with consumer groups and acknowledgment — reach for it when you want more than Pub/Sub but don't want to stand up a separate broker.
- Kafka (or another dedicated broker) remains the right choice for high-volume, cross-team, long-retention, guaranteed-delivery event streaming — Streams' throughput and retention are bounded by a single Redis node's resources.
- A common production pattern bridges both: durable Kafka topic as source of truth, with a lightweight consumer re-publishing to Redis Pub/Sub purely to wake up real-time UI clients.
- In Redis Cluster deployments at scale, prefer
SPUBLISH/SSUBSCRIBE(sharded Pub/Sub, Redis 7+) over plainPUBLISHto avoid cluster-wide broadcast overhead. - Keep Pub/Sub message listeners fast and non-blocking — heavy processing belongs in an async executor, not inline in the subscription callback.
Interview Questions
- How does Redis Pub/Sub differ from a queue — what happens to a published message if no one is subscribed?
- What is the biggest production risk of using Redis Pub/Sub for business-critical events?
- Describe a legitimate use case for Redis Pub/Sub where message loss is acceptable, and explain why it's acceptable there.
- What happens to a slow Pub/Sub subscriber in Redis — is it throttled, buffered, or disconnected?
- How does Redis Streams differ from Redis Pub/Sub? What durability guarantees does it add?
- When would you choose Redis Streams over Kafka, and when would Kafka clearly be the better choice?
- Design a fan-out workflow for order status changes where billing needs guaranteed delivery but the UI just needs a "live" nudge — how would you architect it?
- What is sharded Pub/Sub (
SPUBLISH/SSUBSCRIBE), and what problem does it solve in a clustered Redis deployment? - How would a WebSocket notification system built on Redis Pub/Sub behave during a rolling deploy of the subscriber service?
- What are consumer groups in Redis Streams, and how do they differ from every subscriber receiving every Pub/Sub message?
- If you needed to invalidate a local in-process cache across multiple service instances, would you use Pub/Sub, Streams, or something else? Why?
- What does
XACKdo in Redis Streams, and why doesn't an equivalent concept exist in Pub/Sub? - How would you detect that your system silently dropped Pub/Sub messages during an incident, given that Redis gives you no signal when this happens?