Redis Streams
Backend Fundamentals · Messaging & Event-Driven Architecture · 10 min read
1. What problem does this solve?
Motivation
Modern back‑ends often need to decouple producers (e.g., a web API that receives user actions) from consumers (e.g., background workers that send emails, update analytics, or trigger downstream services). The key requirements are:
- Reliability – a message should survive crashes and be processed at least once.
- Ordering – many workflows depend on the order of events (e.g., order status updates).
- Scalability – many producers and consumers should run concurrently without bottlenecks.
- Back‑pressure handling – producers should not be blocked by slow consumers.
Problem
Traditional in‑memory queues or simple Redis lists (LPUSH/RPOP) lack built‑in consumer groups, pending‑message tracking, or automatic rebalancing. They also provide only at‑most‑once semantics and no visibility into which consumer processed what.
Why it matters
If you cannot guarantee that every event is processed, you risk data loss, inconsistent state, or duplicate work. Without ordering guarantees, downstream services may see events out of sequence, breaking invariants.
Consequences of not using a proper stream
- Duplicate work – a consumer may re‑process a message if it crashes before acknowledging.
- Lost messages – if a consumer never acknowledges and the stream grows indefinitely, you may run out of memory.
- Hard to scale – adding more workers requires manual coordination or custom logic.
Real‑world usage
- Order processing pipelines (e.g., e‑commerce order status updates).
- Event sourcing for domain models.
- Background job queues (image processing, email sending).
- Log aggregation and analytics pipelines.
2. Mental model
Analogy
Imagine a public mailbox that accepts envelopes (messages). Each envelope has a unique serial number (ID). Multiple mail carriers (consumers) can pick up envelopes from the mailbox. When a carrier takes an envelope, they write a note in a shared ledger (consumer group state) that they are handling it. If a carrier drops the envelope (fails before finishing), the ledger still shows it as pending, and another carrier can claim it later.
Engineering model
- Stream = ordered log of entries (envelopes).
- Consumer group = shared ledger of which consumer is processing which entry.
- Consumer = a worker that reads from the stream and acknowledges.
- Pending entries = entries that have been delivered to a consumer but not yet acknowledged.
3. Deep explanation
Internals
- Redis stores a stream as a linked list of entries in memory, each entry having a unique ID (
<timestamp>-<sequence>). - Each entry is a hash of field/value pairs.
- Consumer groups maintain a cursor (offset) per consumer, stored in a separate hash.
- Pending entries are tracked in a ZSET keyed by consumer group, mapping entry IDs to the consumer that owns them.
Important terminology
| Term | Meaning |
|---|---|
XADD | Append an entry to a stream. |
XREADGROUP | Read entries from a stream as part of a consumer group. |
XACK | Acknowledge that a consumer has processed an entry. |
XCLAIM | Claim pending entries that have been idle for too long. |
consumer group | Logical grouping of consumers that share a cursor. |
consumer | Individual worker instance. |
pending entries | Entries delivered but not yet acknowledged. |
idle time | Time since an entry was delivered to a consumer. |
Trade‑offs
| Aspect | Benefit | Drawback |
|---|---|---|
| In‑memory | Low latency, high throughput. | Limited by RAM; no disk persistence unless using RDB/AOF. |
| At‑least‑once | Simple to implement; no message loss. | Requires idempotent consumers or de‑duplication logic. |
| No built‑in exactly‑once | Avoids complex transaction logic. | Duplicate processing risk. |
| Single key per stream | Easy to manage. | No horizontal partitioning unless you shard by key. |
Failure modes
- Consumer crash – pending entries remain unacknowledged; must be claimed later.
- Redis crash – data may be lost if AOF/RDB not configured; replication mitigates.
- Network partition – consumers may read stale data; Redis Cluster can help.
- Stream growth – if not trimmed, memory usage can explode.
Limitations
- No built‑in TTL for entries (must trim manually).
- No exactly‑once semantics.
- Limited to a single Redis instance per stream unless using Redis Cluster.
- Consumer group rebalancing is manual; no automatic partition assignment.
Where it fits
- Message broker – acts as a lightweight broker for event streams.
- Task queue – workers consume tasks from a stream.
- Event sourcing – domain events are appended to a stream.
- Change data capture – database changes can be streamed into Redis.
Interaction with related concepts
- Pub/Sub – Redis Streams provide persistence and consumer groups; Pub/Sub is fire‑and‑forget.
- Redis Lists – simpler but lack consumer groups and pending entry tracking.
- Kafka – similar semantics but more feature‑rich (exactly‑once, compaction, multi‑topic partitions).
- Database triggers – can push events into a stream for downstream processing.
4. How it works
- Producer calls
XADD streamKey * field1 value1 field2 value2 …to append an entry. - Consumer group
myGroupis created withXGROUP CREATE streamKey myGroup $. - Consumer
worker-1reads new entries:
XREADGROUP GROUP myGroup worker-1 COUNT 10 BLOCK 0 STREAMS streamKey >.
The>means “new entries only”. - Redis delivers the entries to
worker-1and records them as pending for that consumer. worker-1processes each entry and acknowledges withXACK streamKey myGroup entryID.- If
worker-1crashes before acknowledging, the entry remains pending. - Another consumer can claim it after a timeout using
XCLAIM streamKey myGroup newConsumer idleTime entryID. - Periodically, the stream can be trimmed with
XTRIM streamKey MAXLEN 100000to free memory.
sequenceDiagram
participant Prod as Producer
participant Redis
participant C1 as Consumer1
participant C2 as Consumer2
Prod->>Redis: XADD streamKey * field1 val1
Redis->>C1: Deliver entry (pending for C1)
C1->>Redis: XACK streamKey myGroup entryID
Note over C1: Entry processed
Prod->>Redis: XADD streamKey * field2 val2
Redis->>C1: Deliver entry (pending for C1)
C1->>Redis: Crash (no ACK)
Redis->>C2: XCLAIM after idle timeout
C2->>Redis: XACK streamKey myGroup entryID
5. Practical implementation
Setup
# Install dependencies
npm install redis
Producer (orders.ts)
import { createClient } from 'redis';
const client = createClient();
await client.connect();
export async function produceOrder(order: { id: string; status: string }) {
// Use auto‑generated ID (*)
await client.xAdd('orders', '*', {
id: order.id,
status: order.status,
});
}
Consumer group creation (init.ts)
import { createClient } from 'redis';
const client = createClient();
await client.connect();
// Create consumer group if it doesn't exist
try {
await client.xGroupCreate('orders', 'order_workers', '