Skip to main content

Redis Pub/Sub

Redis Pub/Sub implements the publish/subscribe messaging paradigm where publishers send messages to channels, and subscribers receive them in real time. It is a fire-and-forget system with no message persistence.

Redis Messaging Paradigm: Pub/Sub vs Redis Streams & Consumer Groups
1. Redis Pub/Sub (At-Most-Once Messaging)Fire-and-Forget

Messages are broadcast immediately to all connected subscribers listening to a channel. Unconnected subscribers miss messages permanently.

Persistence Guarantee
Zero persistence β€” messages exist only in memory during broadcast.
Consumer Dispatch Model
Broadcast / Fan-out: every active subscriber receives a copy of every message.
Primary Use Cases
Real-time notifications, chat rooms, live dashboard updates where missing an entry is acceptable.
Core Redis Commands
PUBLISH orders "order_1001"
SUBSCRIBE orders
PSUBSCRIBE order.*

Internal Mechanics

Understanding what actually happens inside Redis on PUBLISH clarifies most of the failure modes below.

  • Redis maintains an in-memory dictionary mapping channel names to a list of subscribed client connections (pubsub_channels), and a separate radix-tree-like structure for pattern subscriptions (pubsub_patterns).
  • PUBLISH is synchronous and O(N+M) where N is the number of matching direct subscribers and M is the number of matching patterns β€” Redis walks both structures and writes the message directly into each subscriber's client output buffer on the same event loop tick. There is no queue, no disk write, and no intermediate broker state.
  • Because delivery is just "write to the socket buffer of every currently-connected subscriber," a message that arrives when zero clients are subscribed is discarded immediately β€” it is never held anywhere, even briefly.
  • In Redis Cluster, PUBLISH (non-sharded) is propagated to every node via the cluster bus so that clients connected to any node can be subscribed to any channel β€” this is what causes the O(N-nodes) broadcast overhead described later.
  • A single Redis instance is single-threaded for command execution, so a burst of large PUBLISH payloads to many subscribers can transiently block other commands (GET/SET) on the same instance β€” pub/sub is not isolated from your regular workload unless you run it on a dedicated Redis instance.

Core Commands

# SUBSCRIBE β€” listen to one or more channels
SUBSCRIBE news:breaking news:sports

# PSUBSCRIBE β€” pattern subscribe (glob patterns)
PSUBSCRIBE news:* # All news channels
PSUBSCRIBE user:*.events # All user event channels

# PUBLISH β€” send a message to a channel
PUBLISH news:breaking "Redis 8.0 released"
# Returns: number of subscribers who received the message

# UNSUBSCRIBE
UNSUBSCRIBE news:sports # Unsubscribe from specific channel
UNSUBSCRIBE # Unsubscribe from all channels

PUNSUBSCRIBE news:* # Pattern unsubscribe

Subscription Lifecycle

Subscriber A: SUBSCRIBE chat:room1 chat:room2
β†’ waiting for messages...

Publisher: PUBLISH chat:room1 "Hello everyone!"
β†’ Subscriber A receives:
["message", "chat:room1", "Hello everyone!"]

Pattern sub: PSUBSCRIBE chat:*
PUBLISH chat:room2 "New user joined"
β†’ Pattern subscriber receives:
["pmessage", "chat:*", "chat:room2", "New user joined"]

Delivery Semantics

PropertyBehavior
Persistence❌ Zero β€” messages not stored
Delivery guaranteeAt-most-once β€” fire-and-forget
Offline subscribers❌ Miss all messages while disconnected
History/replay❌ Impossible β€” no message log
Message orderingβœ… FIFO within a channel, per-publisher connection only
Acknowledgment❌ No ACK mechanism

Critical: If a subscriber disconnects and reconnects, it will miss all messages published during its absence. Pub/Sub is only appropriate when message loss is acceptable.

A subtlety on ordering: FIFO ordering holds for messages published from a single connection. If two different application instances publish concurrently to the same channel, subscribers see messages in the order Redis's event loop processed the two PUBLISH calls β€” not necessarily the order the two publishers intended, since there's no global sequence number to reconcile against (unlike a Kafka partition offset).


Connection Modes

A subscriber connection enters a blocking subscribe mode β€” it can only receive messages. It cannot send other commands while subscribed (except SUBSCRIBE, UNSUBSCRIBE, PING, RESET, QUIT).

This has a direct architectural consequence for Spring apps: never share a connection between subscribing and normal command execution. RedisMessageListenerContainer manages this correctly by acquiring a dedicated connection from the pool for subscriptions, separate from the pool used by RedisTemplate for regular GET/SET calls. Manually reusing a raw RedisConnection for both will deadlock the connection the moment SUBSCRIBE is issued.

// Spring Boot Redis Pub/Sub
@Configuration
public class PubSubConfig {

@Bean
public RedisMessageListenerContainer listenerContainer(
RedisConnectionFactory factory,
MessageListenerAdapter adapter) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(factory);
container.addMessageListener(adapter, new PatternTopic("user:*.events"));
return container;
}

@Bean
public MessageListenerAdapter listenerAdapter(UserEventListener listener) {
return new MessageListenerAdapter(listener, "onMessage");
}
}

@Component
public class UserEventListener {
public void onMessage(String message, String channel) {
log.info("Received on {}: {}", channel, message);
}
}

// Publisher
@Service
public class EventPublisher {
private final RedisTemplate<String, String> redisTemplate;

public void publishUserEvent(Long userId, String event) {
redisTemplate.convertAndSend("user:" + userId + ".events", event);
}
}

Pattern Subscription with @EventListener

A cleaner Spring approach using RedisMessageListenerContainer + Spring events:

@Service
public class DynamicSubscriberService {

@Autowired
private RedisMessageListenerContainer container;

@Autowired
private ApplicationEventPublisher eventPublisher;

public void subscribe(String channel) {
container.addMessageListener(
(message, pattern) -> {
String body = new String(message.getBody());
eventPublisher.publishEvent(new RedisMessageEvent(channel, body));
},
new ChannelTopic(channel)
);
}
}

@Component
public class MessageHandler {

@EventListener
public void handleRedisMessage(RedisMessageEvent event) {
System.out.println("Event on " + event.getChannel() + ": " + event.getBody());
}
}

Lettuce vs Jedis for Pub/Sub

Spring Boot defaults to Lettuce, and for pub/sub-heavy applications this matters more than it does for simple caching:

Lettuce (default)Jedis
Threading modelNetty-based, async, single shared connection can multiplex pub/sub + commandsBlocking I/O, one thread per connection
Subscriber connection costCheap β€” reuses shared netty event loopRequires a dedicated thread per subscription
Reconnection on failoverAutomatic, built-in reconnect with resubscriptionRequires manual reconnect/resubscribe logic
Recommended forMost Spring Boot apps, especially many concurrent subscriptionsLegacy codebases already standardized on Jedis

If you're running many PSUBSCRIBE listeners per instance (e.g., per-tenant channels), Lettuce's multiplexing avoids the thread-per-subscription cost that Jedis incurs.


Real-World Use Cases

Use CaseWhy Pub/Sub Fits
Live chat (in-memory only)Users online β€” message loss on disconnect OK
Real-time notificationsPush to connected clients
Cache invalidation broadcastAll nodes invalidate cache entry simultaneously
Dashboard live updatesEmit metrics to connected dashboard (tolerate drops)
Debug events / logging broadcastDevelopment environment tracing
WebSocket fan-out via RedisHorizontal scaling of WebSocket servers

Cache Invalidation Pattern

Service A updates product:123
β†’ PUBLISH cache:invalidate "product:123"

Service B (subscribed to cache:invalidate):
β†’ evict("product:123") from local in-process cache
β†’ ensures all nodes' L1 caches are invalidated on write

(Uses Pub/Sub for broadcast β€” doesn't need persistence)
// Publisher: when a product is updated
@CachePut(value = "products", key = "#product.id")
public Product updateProduct(Product product) {
Product saved = repository.save(product);
// Notify all instances to evict their local cache
redisTemplate.convertAndSend("cache-invalidation", "products:" + product.getId());
return saved;
}

// Subscriber: all app instances listen
@Component
public class CacheInvalidationListener implements MessageListener {

@Autowired
private CacheManager cacheManager;

@Override
public void onMessage(Message message, byte[] pattern) {
String key = new String(message.getBody());
String[] parts = key.split(":", 2);
if (parts.length == 2) {
Cache cache = cacheManager.getCache(parts[0]);
if (cache != null) {
cache.evict(parts[1]);
}
}
}
}

Failure mode to know about this exact pattern: if a service instance is restarting (rolling deploy) at the moment the invalidation is published, that instance misses the eviction entirely and serves a stale cached value from its local L1 cache until the entry naturally expires via TTL. Because Pub/Sub gives no delivery guarantee, cache-invalidation-via-pubsub should always be paired with a short TTL as a correctness backstop β€” pub/sub is a latency optimization on top of TTL expiry, not a substitute for it.


Redis can also publish pub/sub events automatically when keys are modified or expire, via keyspace notifications β€” useful for reacting to TTL expiry without polling:

# Enable in redis.conf or via CONFIG SET
CONFIG SET notify-keyspace-events Ex # E = keyevent events, x = expired events

# Subscribe to expiry events for a specific DB
PSUBSCRIBE __keyevent@0__:expired
@Component
public class ExpiredKeyListener implements MessageListener {

@Override
public void onMessage(Message message, byte[] pattern) {
String expiredKey = message.toString();
if (expiredKey.startsWith("session:")) {
log.info("Session expired, cleaning up: {}", expiredKey);
}
}
}

Two gotchas: keyspace notifications are disabled by default (notify-keyspace-events "") because they add CPU overhead per write, and β€” like all pub/sub β€” an expiry event published while no listener is connected is lost forever, so this pattern is only appropriate for best-effort cleanup, never for correctness-critical logic (e.g., don't rely on this alone to release a distributed lock).


Pub/Sub vs Streams

Pub/SubStreams
Persistence❌ Noneβœ… Yes (configurable)
Replay historyβŒβœ… By ID range
Offline client support❌ (miss messages)βœ… (reads from last consumed ID)
Consumer groups❌ (all get all)βœ… (one-to-one distribution within group)
Message acknowledgmentβŒβœ… (XACK)
Pattern matchingβœ… (PSUBSCRIBE)❌ (use separate streams)
Throughput (simple fan-out)Higher (no storage overhead)Slightly lower
Use caseReal-time ephemeral fanoutReliable event queues

Production guidance: Unless you specifically need zero-overhead real-time broadcast and can tolerate message loss, prefer Redis Streams for production message passing.

Pub/Sub vs Kafka (Decision Matrix)

Teams already running Kafka sometimes reach for Redis Pub/Sub out of convenience since Redis is already deployed for caching. Use this to decide:

RequirementChoose Redis Pub/SubChoose Kafka
Sub-millisecond fan-out to many ephemeral clients (WebSocket gateways)βœ…Overkill β€” consumer group rebalancing adds latency
Message must survive a broker restartβŒβœ…
Need replay / reprocessing for a new consumerβŒβœ…
Cross-service durable event sourcingβŒβœ…
Simple broadcast where loss is acceptable (cache invalidation, presence)βœ…Unnecessary operational overhead
Already paying for Redis, no new infra budget, ephemeral use caseβœ…β€”

Sharding Pub/Sub in Redis Cluster

Standard Pub/Sub in Redis Cluster broadcasts to ALL nodes β€” every PUBLISH is forwarded to all cluster nodes, creating O(N-nodes) overhead.

Redis 7.0+: Sharded Pub/Sub

SSUBSCRIBE channel # Subscribe to sharded channel
SUNSUBSCRIBE channel # Unsubscribe from sharded channel
SPUBLISH channel msg # Publish to specific shard only

Sharded Pub/Sub routes channels to a specific hash slot β€” messages only go to the node owning that slot. Dramatically reduces cluster-wide broadcast overhead for high-volume apps.

// Spring Data Redis (2.7+) sharded pub/sub support
@Bean
public RedisMessageListenerContainer shardedListenerContainer(RedisConnectionFactory factory) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(factory);
// Note: as of most Spring Data Redis versions, sharded pub/sub support
// may require using the Lettuce client directly (RedisClusterPubSubCommands)
// rather than the standard RedisMessageListenerContainer β€” verify against
// your Spring Data Redis version before assuming SSUBSCRIBE is wired up.
return container;
}

Observability

Pub/Sub is invisible by default β€” there's no consumer lag metric like Kafka, because there's no log to lag behind. Monitor these instead:

# Check active pub/sub channel and pattern counts
PUBSUB CHANNELS # List all active channels with subscribers
PUBSUB NUMSUB ch1 ch2 # Subscriber count per channel
PUBSUB NUMPAT # Total pattern subscriptions

# Server-level pub/sub stats
INFO stats | grep pubsub
# pubsub_channels: <n>
# pubsub_patterns: <n>

For Spring Boot, expose subscriber health as a custom Micrometer gauge rather than relying on Redis-side inspection alone β€” this catches the case where your application thinks it's subscribed but the underlying connection silently dropped:

@Component
public class PubSubHealthMetrics {

private final AtomicBoolean subscriptionActive = new AtomicBoolean(false);

public PubSubHealthMetrics(MeterRegistry registry) {
Gauge.builder("redis.pubsub.subscription.active", subscriptionActive, b -> b.get() ? 1 : 0)
.description("Whether the expected Redis pub/sub listener container is connected")
.register(registry);
}

@EventListener(RedisMessageListenerContainer.class)
public void onListenerStarted() {
subscriptionActive.set(true);
}
}

Because there is no ACK and no lag metric, the only reliable way to detect "subscribers are silently missing messages" in production is an active heartbeat channel: publish a canary message on a fixed interval and alert if expected subscribers don't report receipt within a threshold.


Production Limitations and Solutions

LimitationProblemSolution
No persistenceMessage loss on disconnectRedis Streams for reliability
No ACKCan't confirm deliveryStreams with XACK
Cluster broadcast overheadO(N) node fanoutRedis 7 Sharded Pub/Sub
Slow subscriber blocksPublisher blocked if subscriber is slow (TCP backpressure)Set client-output-buffer-limit pubsub
Memory pressureSlow subscriber accumulates messages in send bufferLimit buffer: client-output-buffer-limit pubsub 8mb 2mb 60
Silent message loss during failoverRedis Sentinel/Cluster failover drops all active subscriptions; messages published during the failover window are lost with no error to the publisherTreat pub/sub as best-effort only; pair with a durable source of truth (DB row, Stream) for anything that must not be lost
No visibility into subscriber healthA "connected" subscriber may have a stalled consumer loop and never noticeHeartbeat/canary channel + Micrometer gauge as shown above
# Redis config: disconnect slow pub/sub subscribers
client-output-buffer-limit pubsub 8mb 2mb 60
# Hard limit: 8mb (immediate disconnect)
# Soft limit: 2mb for 60 seconds β†’ then disconnect
# Prevents one slow subscriber from consuming all server memory

Common Gotchas & Anti-Patterns

  1. Treating Pub/Sub as a reliable queue. The most common production incident: a team builds order-processing or payment notification logic on Pub/Sub, then loses events during a deploy or Redis failover. If losing a message is unacceptable, it does not belong on Pub/Sub β€” use Streams or Kafka.
  2. Sharing one connection for subscribe and regular commands. Issuing SUBSCRIBE on a connection also used for GET/SET puts that connection into subscribe-only mode and breaks unrelated code paths using the same pooled connection. Let RedisMessageListenerContainer manage its own connection.
  3. Assuming cross-publisher ordering. FIFO only holds per publishing connection; concurrent publishers from multiple app instances can interleave in a non-deterministic order.
  4. Forgetting the TTL backstop on cache invalidation. Relying solely on pub/sub for cache coherence means any dropped message (deploy, network blip, failover) leaves a stale entry indefinitely. Always keep a TTL as the correctness guarantee.
  5. Enabling keyspace notifications globally without considering write overhead. notify-keyspace-events adds a publish on every matching write; on a high-throughput instance this is a nontrivial CPU cost most teams don't budget for.
  6. No slow-subscriber protection configured. Without client-output-buffer-limit pubsub tuned, a single slow consumer (e.g., a WebSocket gateway pod under GC pressure) can grow unbounded memory on the Redis server until it's forcibly disconnected or OOMs the instance.
πŸ“–
Track Page Progress0 / 635 Read
Knowledge Base Completion0%