Redis Streams: Append-Only Logs and Consumer Groups
Objective
Understand what a Redis Stream actually is — "a data structure that acts like an append-only log but also implements several operations to overcome some of the limits of a typical append-only log" — and why Redis 5.0 (2018) added it as a fifth core data type instead of leaving event distribution to Pub/Sub or a hand-rolled List queue. A Stream is an ordered, persisted sequence of entries, each stamped with an ID that encodes when it was written (<milliseconds>-<sequence>), so "the ID is related to the time the entry is generated" and range queries by time come "basically for free." That gets a Stream halfway there: XADD/XRANGE/XREAD alone already beat Pub/Sub at "give me what happened," because the log persists and can be replayed. The other half — and the more important one — is consumer groups: XGROUP/XREADGROUP/XACK/XPENDING/XCLAIM let several independent consumers cooperatively split one stream's workload, with Redis tracking exactly which entry each consumer has and hasn't acknowledged, and a documented path to reassign an unacknowledged entry when its consumer dies. That combination — persisted history plus per-message, per-consumer acknowledgment with failure recovery — is the thing neither Pub/Sub nor a plain List queue has ever offered, and it's the reason Streams exist as their own data type rather than as a recipe built from Lists.
Use Cases
- Event sourcing and activity feeds — the canonical Stream use case cited in Redis's own docs: recording every user action, click, or state change as an immutable, appendable log that downstream code can replay from any point, not just consume live.
- Sensor and IoT telemetry ingestion — many devices
XADD-ing readings into one stream (or one stream per device), where the auto-generated timestamp-encoded ID means "give me every reading between 14:00 and 14:05" is a plainXRANGE, no separate timestamp field or index required. - A durable, replayable notification or chat history — unlike a Pub/Sub channel, where a client that reconnects has simply missed whatever was published while it was offline, a Stream lets a reconnecting client resume exactly where it left off with
XREAD ... STREAMS mystream <last-seen-id>, because the messages are still there to be read. - A work queue processed by a pool of workers, with guaranteed at-least-once processing and crash recovery — the reliability problem the sibling
redis-core-data-types-strings-lists-hashesconcept flags as a real gap in theLPUSH/BRPOPqueue pattern (an item popped byBRPOPis simply gone if the consumer crashes before finishing it, and even theRPOPLPUSH-into-a-processing-list mitigation still requires the consumer to remember to clean up after itself). A consumer group's Pending Entries List (PEL) plusXCLAIM/XAUTOCLAIMsolves exactly this, natively, which is why that concept calls Streams the pattern's eventual replacement. - Fan-out to multiple independent downstream services, each processing every event at its own pace — order-placed events feeding both a billing service and a fulfillment service, where each service needs to see every event exactly once from its own point of view, not race each other for messages the way two Pub/Sub subscribers or two consumers on one List queue would. Two separate consumer groups on the same stream give each service its own independent read position and its own PEL.
Deep Dive
The Stream itself: an append-only log addressed by time-ordered IDs
XADD key <id|*> field value [field value ...] appends one entry — a small ordered set of field-value pairs — to the stream at key, creating the key if it doesn't exist (NOMKSTREAM suppresses that). Passing * as the ID tells Redis to auto-generate one in the form <milliseconds>-<sequence>, where the milliseconds part is the server's Unix time at the moment of the write and the sequence part disambiguates multiple entries written in the same millisecond. Redis's own guarantee is explicit: "Redis guarantees that IDs are always incremental: the ID of any entry you insert will be greater than any previous ID, so entries are totally ordered inside a stream" — and if the local clock ever jumps backward (a clock adjustment, a failover to a replica with a different clock), Redis keeps using the previous top ID's timestamp and just increments the sequence, so monotonicity never breaks. An explicit ID can be supplied instead of * (useful mainly when mirroring IDs from another system), but XADD rejects any ID that isn't strictly greater than the stream's current top ID.
| Command | Does |
|---|---|
XADD key [NOMKSTREAM] [MAXLEN|MINID [~] threshold] <*|id> field value [...] |
Appends an entry; * auto-generates the ID. MAXLEN/MINID optionally cap the stream's size in the same call — ~ requests fast, approximate trimming instead of exact |
XLEN key |
Number of entries in the stream, O(1) |
XRANGE key start end [COUNT n] |
Entries with IDs between start and end inclusive, oldest first; -/+ mean smallest/greatest possible ID, and a (-prefixed ID makes that end exclusive |
XREVRANGE key end start [COUNT n] |
Same as XRANGE but newest first |
XDEL key id [id ...] |
Removes specific entries by ID |
XTRIM key MAXLEN|MINID [~] threshold |
Evicts the oldest entries beyond a length or ID threshold — the same trimming XADD ... MAXLEN performs inline |
Because the ID is a timestamp plus a tiebreaker, XRANGE mystream 1691765278160 1691765279999 is a legitimate time-range query with no secondary index — the ordering the log already has for free doubles as the index.
XREAD: the basic log-consumption model
XREAD [COUNT n] [BLOCK ms] STREAMS key [key ...] id [id ...] reads entries with an ID greater than the one supplied, across one or more streams at once, and can block for up to ms milliseconds (or indefinitely with BLOCK 0) waiting for new entries when $ is given as the ID (meaning "only entries added after this call started"). This is the closest Stream analogue to what Pub/Sub already does — deliver whatever comes in — except every reader can independently pick where in the log to start, and rereading the same range twice is just two XREADs with the same IDs. What plain XREAD does not do is remember, on the server side, what any particular client has already consumed, or coordinate delivery across multiple readers so each entry goes to only one of them — every client calling XREAD on a stream sees every entry, same as SUBSCRIBE would, just with the added ability to also ask for history. Turning that into cooperative, tracked, exactly-one-consumer-per-entry delivery is what consumer groups add.
Consumer groups: turning a shared log into a coordinated work queue
XGROUP CREATE key group <id|$> [MKSTREAM] creates a named cursor over a stream: "the command's id argument specifies the last delivered entry in the stream from the new group's perspective. The special ID $ is the ID of the last entry in the stream, but you can substitute it with any valid ID." Creating a group with $ means it only ever sees entries added after creation — history already in the stream is invisible to it unless 0 (or another earlier ID) is used instead. MKSTREAM atomically creates the stream too, for the common case of provisioning a group before any producer has written anything.
XREADGROUP GROUP group consumer STREAMS key > is the consumer-group read: the special ID > means "give me only entries that were never delivered to any consumer in this group." Multiple consumers reading the same group with > split the stream's entries between them — "if, for instance, the stream gets the new entries A, B, and C and there are two consumers reading via a consumer group, one client will get, for instance, the messages A and C, and the other the message B" — which is the sharding/partitioning behavior a plain XREAD or a Pub/Sub channel cannot express, since both of those deliver every message to every listener. Every entry handed out this way lands in the group's Pending Entries List (PEL): "one of the guarantees of consumer groups is that a given consumer can only see the history of messages that were delivered to it, so a message has just a single owner," and Redis tracks, per entry, which consumer holds it, how long it's been held, and how many times it's been delivered. A stream can carry several independent consumer groups at once, each with its own last-delivered pointer and its own PEL — the mechanism behind the "two services, each sees every event once, independently" fan-out use case above.
XACK, XPENDING, and recovering from a dead consumer
A consumer finishes processing an entry by calling XACK key group id, which "will immediately remove the pending entry from the Pending Entries List (PEL) since once a message is successfully processed, there is no longer need for the consumer group to track it and to remember the current owner of the message." Anything still sitting in the PEL is, by definition, work that was handed out but never confirmed done.
XPENDING key group (no range) returns a summary — total pending count, lowest and highest pending ID, and a per-consumer breakdown of how many entries each one is holding. Adding a range and count (XPENDING key group - + 10, optionally filtered by IDLE min-ms or a specific consumer) switches to the extended form: one row per pending entry, giving its ID, current owning consumer, milliseconds since last delivery, and delivery count. That's the observability surface for "what's stuck and with whom."
Recovery from a crashed or hung consumer is XCLAIM key group new-consumer min-idle-time id [id ...], which reassigns ownership of entries that have been pending for at least min-idle-time milliseconds to a different, presumably healthy consumer — bumping their delivery counter each time. XAUTOCLAIM key group new-consumer min-idle-time start [COUNT n] (added in Redis 6.2) does the same scan-and-reclaim automatically with a returned cursor, instead of requiring the caller to already know which IDs are stuck. The documented recovery loop after an actual consumer crash is simpler still: call XREADGROUP with 0 (or any specific old ID) instead of > — "any other ID... will have the effect of returning entries that are pending for the consumer sending the command" — to replay a consumer's own unacknowledged backlog before rejoining the group with > for new messages. This delivery-tracking-plus-reclaim loop is exactly what makes consumer groups at-least-once: an entry is never silently dropped the way a Pub/Sub message is, because it stays in some consumer's PEL, claimable, until somebody XACKs it.
Streams vs. Pub/Sub vs. Lists-as-a-queue
This is the comparison that actually explains why Streams exist as their own data type. Redis's own Pub/Sub documentation states the gap plainly: "Redis' Pub/Sub exhibits at-most-once message delivery semantics. As the name suggests, it means that a message will be delivered once if at all. Once the message is sent by the Redis server, there's no chance of it being sent again. If the subscriber is unable to handle the message... the message is forever lost. If your application requires stronger delivery guarantees, you may want to learn about Redis Streams. Messages in streams are persisted, and support both at-most-once as well as at-least-once delivery semantics." A List used as a queue (LPUSH/BRPOP) sits in between: an item does persist until popped, but once popped it's gone from Redis entirely, and there's no built-in mechanism tracking whether the popping consumer actually finished with it — the RPOPLPUSH-into-a-processing-list idiom papers over that, by hand, without acknowledgment, retry counts, or a way to inspect what's stuck.
| Pub/Sub | List as queue | Stream + consumer group | |
|---|---|---|---|
| Message persists after delivery? | No — never stored | Until popped, then gone | Until trimmed, regardless of consumption |
| Late/reconnecting subscriber sees history? | No — missed entirely | N/A (item already consumed) | Yes — XRANGE/XREAD from any earlier ID |
| Multiple independent consumer groups off one source? | No — every subscriber gets everything | No — one queue, one logical consumer per item | Yes — each group has its own cursor and PEL |
| Per-message ack + redelivery on failure? | No | Only hand-rolled (RPOPLPUSH + manual cleanup) |
Native — XACK, XPENDING, XCLAIM/XAUTOCLAIM |
| Delivery guarantee | At-most-once | Effectively at-most-once unless hand-built | At-least-once (with consumer groups) |
Each consumer group reads the same underlying log independently — billing and fulfillment each see every order entry exactly once from their own perspective, and a dead consumer-a doesn't lose its in-flight entries, it just leaves them claimable in the billing group's PEL.
Trade-offs
- Streams trade Pub/Sub's zero-setup simplicity for real operational machinery. A Pub/Sub channel needs no key, no group, no cleanup —
SUBSCRIBE/PUBLISHand you're done. A Stream needsXGROUP CREATEper consumer group, a trimming policy, and application code that callsXACKand handlesXPENDING/XCLAIMfor recovery. That's the right trade only when replay, ack, or multi-consumer-group fan-out are actually needed — a live "who's online" broadcast or a simple cache-invalidation ping is still better served by Pub/Sub's at-most-once, fire-and-forget model. - A Stream grows forever unless something trims it. Unlike a List queue, which self-drains as items are popped,
XADDnever removes anything on its own —MAXLEN/MINID(either inline onXADDor via a separateXTRIM) is a decision the application has to make deliberately, and forgetting it means unbounded memory growth. Trimming interacts with consumer groups, too: as of Redis 8.2,XADD/XTRIM'sKEEPREF/DELREF/ACKEDoptions control whether trimmed entries still referenced by a slow consumer group's PEL are removed anyway (KEEPREF, the default), force-removed from every group's PEL (DELREF), or only removed once every group has acknowledged them (ACKED) — the fan-out use case (multiple independent groups) makes "the slowest group" a real constraint on how aggressively a stream can be trimmed. - At-least-once means possibly-more-than-once — Streams don't give you exactly-once for free.
XCLAIM/XAUTOCLAIMreassign an entry once it's been idle past a threshold, but "idle" can mean "the original consumer is dead" or just "the original consumer is slow." A wrongly-tuned idle threshold can hand the same entry to a second consumer while the first one is still legitimately working on it — processing logic still needs to be idempotent, the same discipline any at-least-once system requires. (This is a consumption-side concern; Redis 8.6'sIDMP/IDMPAUTOoptions onXADDsolve a different problem — deduplicating a producer that might retry the same write.) - A consumer group created with
$silently starts blind to history.XGROUP CREATE key group $is a common default, but it means that group's firstXREADGROUP ... >call only ever returns entries added after the group existed — a group created to backfill or audit needs0(or an explicit earlier ID) instead, and this is a one-time choice made at creation, not something adjusted painlessly later (thoughXGROUP SETIDcan move a group's cursor after the fact). - Choosing among Pub/Sub, a List queue, and a Stream is a delivery-guarantee decision, not a preference. The comparison table above is the whole decision: reach for Pub/Sub when losing a message under a dead or absent subscriber is acceptable and simplicity matters most; reach for a List when there's exactly one logical queue with one class of consumer and the reliable-queue idiom's manual bookkeeping is tolerable; reach for a Stream the moment more than one independent consumer (or consumer group) needs to see the same events, or a crashed consumer's in-flight work needs to be recoverable rather than silently lost.
Documentation Links
- Redis Documentation — Redis Streams
- Redis Documentation — XADD
- Redis Documentation — XREAD
- Redis Documentation — XGROUP CREATE
- Redis Documentation — XREADGROUP
- Redis Documentation — XACK
- Redis Documentation — XPENDING
- Redis Documentation — XCLAIM
- Redis Documentation — XAUTOCLAIM
- Redis Documentation — Pub/Sub (delivery semantics)