Streams

advanced redis streams event-sourcing consumer-groups

Streams are Redis’s answer to “I want Kafka but I already have Redis.” They’re an append-only log of entries, each with a monotonically increasing ID, supporting consumer groups for distributed processing, message acknowledgment, and replay. Added in Redis 5.0.

In simple language: think of it like a list that remembers everyone who’s read what, lets multiple workers split the work, and lets a new consumer catch up from the beginning whenever it wants.

The basics: XADD and XREAD

Each entry is a set of field-value pairs and gets an ID like 1716720000123-0 (milliseconds-sequence).

XADD events * type "login" userId "42"
# "1716720000123-0"

XADD events * type "purchase" userId "42" amount "99.99"
# "1716720000456-0"

XLEN events
# (integer) 2

XRANGE events - +
# 1) 1) "1716720000123-0"
#    2) 1) "type"
#       2) "login"
#       3) "userId"
#       4) "42"
# 2) ...

The * tells Redis to auto-generate the ID. The - and + in XRANGE mean “from beginning” and “to end.”

Reading new entries

XREAD blocks until new entries arrive, like BLPOP for streams:

XREAD BLOCK 0 STREAMS events $
# $ means "only entries added AFTER this call"

Consumer groups — the killer feature

This is what makes streams Kafka-lite. A consumer group lets multiple workers split the stream, each entry delivered to exactly one consumer in the group, with explicit acknowledgment.

Stream: events + Consumer Group: workers
stream
e1, e2, e3, e4, e5 ...
→ XREADGROUP →
worker-A: e1, e3
worker-B: e2, e5
worker-C: e4
→ XACK
Pending Entries List (PEL) tracks unacked messages per consumer for recovery

Setting up

# Create the group, starting from the very beginning
XGROUP CREATE events workers $ MKSTREAM
# $ = "start from new entries only", 0 = "start from beginning"

# Worker A reads
XREADGROUP GROUP workers worker-A COUNT 10 BLOCK 0 STREAMS events >

# > = "give me entries never delivered to anyone in this group"

# After processing
XACK events workers 1716720000123-0

What if a worker crashes?

Redis remembers every entry delivered but not yet acked — the Pending Entries List (PEL). We can inspect it and reassign stuck entries:

XPENDING events workers
# Summary: how many pending per consumer

XPENDING events workers - + 10 worker-A
# Detail: specific entry IDs that worker-A hasn't acked

XCLAIM events workers worker-B 60000 1716720000123-0
# Reassign that entry from whoever to worker-B if it's been pending > 60s

Newer Redis has XAUTOCLAIM which automates this — sweep up entries pending longer than X and reassign them.

Streams vs Lists vs Kafka

  • Lists (LPUSH/BRPOP): one consumer wins each item, no replay, no ack. Simple work queue.
  • Streams: many consumers per group, explicit ack, replay from any point, multi-group fanout. Real event log.
  • Kafka: same shape as streams, plus partitioning across brokers, infinite retention, exactly-once semantics, mature ecosystem. Use Kafka if you’re already operating it or if you need its scale.

For most apps under ~100k events/sec, Redis streams are plenty.

Real-world: order processing

// Producer (web server)
await redis.xadd("orders", "*",
  "orderId", id, "userId", userId, "amount", amount
);

// Consumer (worker)
while (true) {
  const res = await redis.xreadgroup(
    "GROUP", "fulfillment", "worker-1",
    "COUNT", 10, "BLOCK", 0,
    "STREAMS", "orders", ">"
  );
  for (const [stream, entries] of res) {
    for (const [id, fields] of entries) {
      await processOrder(fields);
      await redis.xack("orders", "fulfillment", id);
    }
  }
}

Trimming

Streams grow forever unless we trim. Use MAXLEN to keep only the most recent N:

XADD events MAXLEN ~ 1000000 * type "login" userId "42"
# ~ means "approximate" — way cheaper than exact trimming

XTRIM events MAXLEN 500000

The interview soundbite

“Streams give Redis a real event log with consumer groups and ack semantics, so we can build fan-out, retry, and replay without dragging in Kafka. The Pending Entries List + XCLAIM is the recovery story — that’s what makes them ‘reliable’ rather than fire-and-forget like lists.”