06-caching-locking-performance

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.

August 14, 2026
backend-engineerpubsubfan-outnotificationsreal-time

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

bash
# 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 channel
💡

If 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.

java
@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;
    }
}
java
@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
        }
    }
}
java
@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 caseWhy Pub/Sub fits
Live WebSocket fan-outBrowser is either connected right now or it isn't — no benefit to persisting a message for a disconnected client
Cache invalidation across instancesEach 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" indicatorsInherently ephemeral — stale presence data is worse than a missed update
Multi-instance config hot-reloadNotify all instances to re-read config; each instance can also poll periodically as a fallback
Real-time dashboards / live metrics ticksNext 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."

java
@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

GuaranteeRedis Pub/SubWhat you actually need instead
Message persistenceNone — undelivered messages vanish instantlyRedis Streams (bounded log) or Kafka (durable log)
Delivery to offline consumersNone — must be subscribed at publish timeStreams (XREAD from a position) or a queue-based broker
At-least-once delivery / acknowledgmentNone — fire and forget, no ACK conceptStreams consumer groups (XACK) or Kafka/RabbitMQ
Consumer groups (load-balanced fan-out)None — every subscriber gets every messageStreams consumer groups, Kafka consumer groups
Replay / historical readNone — no concept of position or offsetKafka (retained log), Streams (bounded log with IDs)
Ordering guarantees across partitions/shardsBest-effort within one Redis node onlyKafka partitions with keyed ordering
Backpressure handlingNone — 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.

bash
# 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
java
@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

AspectRedis Pub/SubRedis StreamsKafka
PersistenceNoneYes (bounded log, MAXLEN trimming)Yes (configurable retention, disk-backed log)
Delivery to offline consumersNoYes (read from any position)Yes (read from any offset)
Consumer groupsNoYesYes
Acknowledgment / redeliveryNoYes (XACK, pending entries list)Yes (offset commits)
Ordering guaranteeBest-effort, single nodePer-stream orderingPer-partition ordering, high throughput
Horizontal scale for huge volumeN/ALimited (single Redis node bottleneck for one stream)Built for this — partitioning across brokers
Operational complexityMinimalLow-moderateHigher — dedicated cluster, ZooKeeper/KRaft
Best forEphemeral real-time fan-outDurable but modest-throughput event log within a Redis-centric stackHigh-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.

java
@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.

bash
# 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-limit for 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 plain PUBLISH to 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 XACK do 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?