Pub/Sub and Redis Streams: Differences and How to Use with Spring Boot
1. Overview
Redis provides two different messaging mechanisms: Pub/Sub and Streams. Many developers new to Redis often confuse these two as one, leading to choosing the wrong tool and losing data when the system restarts.
This article explains the core differences between Pub/Sub and Streams, provides you with working Java code for both, and clearly states when to use which. After reading, you’ll confidently choose the right messaging model for your real-world messaging needs.
2. What is Redis Pub/Sub
Pub/Sub (Publish/Subscribe) operates on a “fire-and-forget” model. A publisher sends a message to a channel, and Redis immediately forwards it to all subscribers listening to that channel. If no subscriber is online, the message disappears permanently.
2.1. Maven Dependency
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2.2. Publisher
@Service
public class OrderEventPublisher {
private final StringRedisTemplate redisTemplate;
public OrderEventPublisher(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void publish(String orderId, String status) {
String message = "orderId=%s;status=%s".formatted(orderId, status);
redisTemplate.convertAndSend("order-events", message);
}
}
convertAndSend sends a message to the order-events channel. If no one is subscribed yet, this command still succeeds but the message is not stored anywhere.
2.3. Subscriber
@Component
public class OrderEventListener implements MessageListener {
@Override
public void onMessage(Message message, byte[] pattern) {
String body = new String(message.getBody());
System.out.println("Received event: " + body);
}
}
@Configuration
public class RedisPubSubConfig {
@Bean
public RedisMessageListenerContainer container(
RedisConnectionFactory connectionFactory,
OrderEventListener listener) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.addMessageListener(listener, new ChannelTopic("order-events"));
return container;
}
}
RedisMessageListenerContainer manages a dedicated connection (using blocking connection) to listen to the order-events channel. When a publisher sends a message, onMessage is called immediately.
Output when publishing an event:
Received event: orderId=ORD-001;status=CONFIRMED
Note: If
OrderEventListenerhasn’t started yet (container hasn’t bound), any message published before that is lost completely, with no way to replay it.
3. What is Redis Streams
Stream is an ordered, append-only log data structure where each message has a unique ID in the format <timestamp>-<sequence>. Unlike Pub/Sub, Streams persist messages in Redis until you explicitly delete them (XDEL) or trim (XTRIM). Consumers can read from the beginning, read by group (consumer group) with an acknowledgment mechanism similar to Kafka.
3.1. Adding a message to a Stream
@Service
public class OrderStreamPublisher {
private final StringRedisTemplate redisTemplate;
public OrderStreamPublisher(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public RecordId publish(String orderId, String status) {
Map<String, String> payload = Map.of(
"orderId", orderId,
"status", status
);
return redisTemplate.opsForStream()
.add("order-stream", payload);
}
}
opsForStream().add writes a record to the order-stream and returns a RecordId — like an auto-generated primary key for that record. This record exists in Redis until it’s trimmed or deleted, regardless of whether anyone has read it.
3.2. Reading a Stream using Consumer Group
@Component
public class OrderStreamConsumer {
private final StringRedisTemplate redisTemplate;
public OrderStreamConsumer(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
createGroupIfAbsent();
}
private void createGroupIfAbsent() {
try {
redisTemplate.opsForStream()
.createGroup("order-stream", "order-processors");
} catch (Exception e) {
// Group already exists, ignore
}
}
public void pollOnce() {
List<MapRecord<String, Object, Object>> records = redisTemplate.opsForStream()
.read(Consumer.from("order-processors", "worker-1"),
StreamReadOptions.empty().count(10),
StreamOffset.create("order-stream", ReadOffset.lastConsumed()));
for (MapRecord<String, Object, Object> record : records) {
System.out.println("Processing record " + record.getId() + ": " + record.getValue());
redisTemplate.opsForStream().acknowledge("order-stream", "order-processors", record.getId());
}
}
}
createGroupcreates theorder-processorsconsumer group, similar to a consumer group in Kafka.Consumer.from("order-processors", "worker-1")declares a consumer namedworker-1belonging to that group. Multiple workers in the same group will share records, without reading duplicates.ReadOffset.lastConsumed()means read new records that haven’t been assigned to any consumer in the group yet.acknowledgeconfirms that processing is complete, Redis removes the record from the group’s “pending list”.
Output:
Processing record 1696500000000-0: {orderId=ORD-001, status=CONFIRMED}
Note: If you forget to call
acknowledge, the record stays in the pending list. Another consumer or this consumer itself (after restart) can useXCLAIM/readPendingto reclaim and reprocess it — this is the “at-least-once delivery” mechanism that Pub/Sub doesn’t have.
4. Direct Comparison of Pub/Sub and Streams
| Criterion | Pub/Sub | Streams |
|---|---|---|
| Message storage | No | Yes, until trimmed/deleted |
| Replay old messages | No | Yes (XRANGE, custom offset) |
| Consumer offline loses what | All messages sent while offline | Nothing, can read again when online |
| Load balancing multiple consumers | Each subscriber gets a copy (broadcast) | Consumer group shares, each record processed by one consumer |
| Acknowledge / retry | No | Yes (XACK, XCLAIM) |
| Complexity | Very simple | More complex, similar to Kafka |
| Suitable use case | Real-time notifications, delivery not guaranteed | Event sourcing, job queue, guaranteed processing |
4.1. When to use Pub/Sub
Use Pub/Sub when you need instant broadcast and accept that messages may be lost if no one is listening. Typical examples: “typing…” status updates in chat, cache synchronization across multiple application instances (cache invalidation), or real-time notifications that don’t matter if a few are missed.
@Service
public class CacheInvalidationPublisher {
private final StringRedisTemplate redisTemplate;
public CacheInvalidationPublisher(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void invalidate(String cacheKey) {
redisTemplate.convertAndSend("cache-invalidation", cacheKey);
}
}
Each application instance subscribes to the cache-invalidation channel and receives keys to remove from its local cache. If an instance is restarting and misses the event, it just uses the old cache for a short time — not a critical error.
4.2. When to use Streams
Use Streams when messages are important business data that need complete processing: orders, payment transactions, audit logs. You need to ensure that a consumer crashing mid-process can still reprocess the record after restart.
@Service
public class PaymentEventPublisher {
private final StringRedisTemplate redisTemplate;
public PaymentEventPublisher(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public RecordId publish(String paymentId, BigDecimal amount) {
Map<String, String> payload = Map.of(
"paymentId", paymentId,
"amount", amount.toString()
);
return redisTemplate.opsForStream().add("payment-stream", payload);
}
}
Each payment event is written to payment-stream. Even if PaymentWorker is down for deployment, this event stays ready in Redis; the worker will continue reading when it restarts.
5. Writing Tests for Stream Consumer
Since Streams have persistent state, you should thoroughly test the publish–consume–acknowledge flow. Use Testcontainers to run real Redis, avoiding incorrect mock behavior.
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
@Testcontainers
class OrderStreamConsumerTest {
@Container
static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
.withExposedPorts(6379);
StringRedisTemplate redisTemplate;
@BeforeEach
void setUp() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(
redis.getHost(), redis.getFirstMappedPort());
LettuceConnectionFactory factory = new LettuceConnectionFactory(config);
factory.afterPropertiesSet();
redisTemplate = new StringRedisTemplate(factory);
}
@Test
void shouldConsumeAndAcknowledgeRecord() {
OrderStreamPublisher publisher = new OrderStreamPublisher(redisTemplate);
OrderStreamConsumer consumer = new OrderStreamConsumer(redisTemplate);
publisher.publish("ORD-002", "SHIPPED");
consumer.pollOnce();
PendingMessages pending = redisTemplate.opsForStream()
.pending("order-stream", Consumer.from("order-processors", "worker-1"),
Range.unbounded());
assertThat(pending.getTotalPendingMessages()).isZero();
}
}
This test publishes a record, calls pollOnce() to consume and acknowledge, then checks that the pending list is empty — meaning the record has been confirmed as processed and is no longer pending in the group.
6. Common Mistakes
- Using Pub/Sub for important data: many newcomers use Pub/Sub to send order events then are surprised when consumer restart loses the event. Pub/Sub is not a message queue.
- Not trimming Streams: Streams don’t auto-delete old data. If you publish continuously without calling
XTRIMor settingMAXLENwhen adding, Redis memory will grow over time.
redisTemplate.opsForStream().add(
StreamRecords.newRecord()
.in("order-stream")
.ofMap(payload),
RedisStreamCommands.XAddOptions.maxlen(10_000)
);
- Forgetting to create consumer group before reading: the
XREADGROUPcommand will fail if the group doesn’t exist. Always callcreateGroupwith try-catch to handle the “BUSYGROUP” error (group already exists). - Not setting connection timeout for Pub/Sub: subscribers use a dedicated blocking connection; if the network breaks and there’s no reconnection mechanism, you lose all messages from that moment until you detect and reconnect.
7. Conclusion
Pub/Sub and Streams solve two different problems even though both live in Redis. Pub/Sub is simple, fast, but guarantees nothing — suitable for instant notifications that can be skipped. Streams are more complex but durable, with consumer groups and acknowledgment — suitable for business data that needs complete processing, like a lightweight message queue.
If your system needs even higher reliability (exactly-once, large partitioning, many concurrent consumer groups at scale), consider Kafka or RabbitMQ instead of pushing Redis Streams to do too much.