What Are Redis Streams?

A Redis stream is an append-only log data structure. Every entry gets a time-ordered ID and a set of field-value pairs, entries are never modified once written, and many consumers can read the same stream independently — or cooperatively, through consumer groups.

That last part separates streams from every other Redis data type. A list forgets a value the moment you pop it; a pub/sub channel forgets a message the moment it is delivered. A stream remembers both the data and what each group of readers has already seen — which is why it is the only Redis structure you can build a reliable work queue or an event log on without inventing the bookkeeping yourself.

Streams arrived in Redis 5.0 and are also implemented in Valkey, though as of Redis 8.x the two have diverged considerably.

How a Redis Stream Works

Each entry has an ID of the form <milliseconds>-<sequence>1730900000000-0. IDs are strictly increasing, so they double as a cursor: "everything after 1730900000000-0" is a well-defined, cheap range query. Pass * and the server generates the next ID for you. Pass <ms>-* (Redis 7.0+) and you fix the millisecond while letting the server pick the sequence number.

The payload is a flat set of field-value pairs, not an opaque blob — closer to a Redis hash than to a string. Internally a stream is a radix tree whose leaves are macro nodes: listpacks holding many entries each. That sounds like trivia until you trim a stream, at which point it decides the outcome.

One behaviour catches people out. Unlike lists, sets and hashes, an empty stream is not deleted. Trim it to zero entries and the key remains, deliberately, because the consumer group state lives on the key. Two consequences: EXISTS is not a proxy for "has data", and DEL on a stream destroys every group and every pending record along with it.

The log is shared; the group tracks who has seen what Producer XADD …000-0 …001-0 …002-0 …003-0 append-only · IDs strictly increasing · entries never modified consumer group "workers" consumer-1 XREADGROUP consumer-2 XREADGROUP consumer-3 crashed Pending Entries List (PEL) …003-0 · owner: consumer-3 · idle: 94,000 ms · delivered: 1 — waits until claimed

Redis Streams vs. Pub/Sub vs. Lists

All three can move messages between processes. They fail in completely different ways, and picking the wrong one is the most common mistake in this area.

Pub/SubListStream
Consumer offlineMessage lostMessage waitsMessage waits
Replay old messagesNoNo — popping removesYes, by ID or timestamp
AcknowledgmentNoneNone (BLMOVE approximates it)Per message, server-side
Many readers, same messageYes — all subscribersNo — first taker winsEither, depending on group
Crashed consumerMessage goneMessage gone with itRecoverable from the PEL
MemoryNothing retainedShrinks as consumedGrows until trimmed

The short version: pub/sub is fire-and-forget broadcast, a list is a queue that forgets, and a stream is a log that remembers. The cost of remembering is that a stream is the only one of the three that grows without bound unless you tell it not to.

If you need one message delivered to many independent subscribers and durability, streams give you that too: create several consumer groups on the same stream and each maintains its own position. That is the fanout pattern, with replay.

Writing to a Stream: XADD

XADD both creates a stream and appends to it:

XADD orders * customer 4711 sku A-22 qty 3

The * asks the server for the next ID. The modifiers that matter: NOMKSTREAM appends only if the stream already exists, instead of silently creating one from a typo'd key; MAXLEN caps by entry count; MINID caps by minimum ID, which is how you express time-based retention; and LIMIT bounds how much work a single trim may do.

Capping matters more than anything else on this page, because an uncapped stream is a memory leak with good ergonomics:

XADD orders MAXLEN ~ 10000 * customer 4711 sku A-22 qty 3

Why ~ Does Not Mean What Most People Think

The ~ requests approximate trimming, and the approximation is bounded by macro nodes, not by your threshold. Redis stops trimming early when a whole node cannot be freed, because freeing a node is one deallocation and splitting one is not. The documentation is explicit that after trimming, "the stream may have a few tens of additional entries over the threshold."

So MAXLEN ~ 1 does not produce a one-entry stream — it produces roughly one macro node. Teams that set an aggressive approximate cap and then alert on XLEN are chasing a bug that does not exist. Use = when the exact length is a contract, ~ — far cheaper — when it is a budget.

The other half of the trap is LIMIT. Unspecified, it defaults to 100 times the entries in a macro node, and that ceiling applies per call — so under a write burst a single XADD ... MAXLEN ~ may not reach your threshold at all. LIMIT 0 disables the ceiling and trims until done, at the cost of a longer-running command.

Reading a Stream: XREAD and XRANGE

XREAD reads without any group bookkeeping — every reader sees every entry, and the server records nothing:

XREAD COUNT 10 BLOCK 5000 STREAMS orders $

$ means "only entries that arrive after this call". 0 means "from the beginning". Any explicit ID means "everything after this one". BLOCK 5000 waits up to five seconds rather than returning empty; BLOCK 0 waits indefinitely.

XRANGE and XREVRANGE handle replay over a closed interval. Because IDs lead with a millisecond timestamp, a time range is just an ID range:

XRANGE orders 1730900000000 1730903600000 COUNT 100
XRANGE orders - + COUNT 1

Redis 8.10 added MAXCOUNT and MAXSIZE to XREAD and XREADGROUP, capping the cumulative entry count and byte size of a reply — a guard against a single read pulling back more than the client can absorb. Valkey has no equivalent.

Consumer Groups

A consumer group turns a stream from a shared log into a work queue. The group holds one cursor, and the server hands each new entry to exactly one member — but unlike a list, it remembers what it handed out.

XGROUP CREATE orders workers $ MKSTREAM
XREADGROUP GROUP workers consumer-1 COUNT 10 BLOCK 5000 STREAMS orders >
XACK orders workers 1730900000000-0

$ starts the group at the current end of the stream; 0 starts it at the beginning and replays everything. MKSTREAM creates the key if it does not exist — without it, XGROUP CREATE fails on a missing stream. Creating a group that already exists returns -BUSYGROUP, which is normally safe to swallow at startup.

The > in XREADGROUP means "entries never delivered to anyone". Any other ID means something quite different: "my own pending entries with IDs greater than this". That distinction is the whole recovery story.

The Pending Entries List

When an entry is delivered to a consumer it moves into the group's pending entries list (PEL) and stays there until acknowledged. Each PEL record holds the message ID, the owning consumer, the time of last delivery, and a delivery counter.

This is the mechanism behind at-least-once delivery, and also the thing that most often goes wrong in production, for one reason:

Nothing ever expires a PEL entry. When a consumer dies, its unacknowledged messages do not time out, return to the queue, or get reassigned. They sit in the PEL, owned by a process that no longer exists, accumulating idle time forever. Valkey's documentation puts the consequence plainly: otherwise "the server will leave the messages pending forever and assigned to the old consumer."

Recovery is your application's job, along two paths.

Restarting a consumer that crashed mid-batch. Call XREADGROUP with an ID of 0 instead of > to get your own pending history back. Loop until it returns empty, then switch to > for new work.

Taking over from a consumer that is not coming back. XAUTOCLAIM scans the PEL with a cursor and reassigns anything idle beyond a threshold:

XAUTOCLAIM orders workers consumer-2 60000 0-0 COUNT 100

Since Redis 7.0 its reply has three elements: the next cursor (0-0 means the scan completed), the claimed messages, and a list of IDs that no longer exist in the stream and were purged from the PEL. That third element matters — see the next section.

Redis 8.4 folded claiming into the read. XREADGROUP ... CLAIM <min-idle-time> ... > reclaims idle pending entries and reads new ones in one round trip, sharing a single COUNT budget, with claimed entries returning their idle time and delivery count inline — removing the separate reaper loop most teams write by hand. Valkey has no CLAIM option.

One more trap: consumer names persist too. Claiming a dead consumer's messages does not remove its record, so naming consumers after pods leaves XINFO CONSUMERS accumulating dead entries until you call XGROUP DELCONSUMER.

Trimming, Deletion and the Pending Entries List

Here is the interaction that turns a capped stream into an incident, and it is not obvious from either command's documentation on its own.

Trimming a stream does not trim the PEL. Deleting an entry that is currently pending leaves the ID in the PEL with nothing behind it. The XREADGROUP documentation is exact: "the PELs retain the deleted entries' IDs, but the actual entry payload is no longer available. Therefore, when reading such PEL entries, Redis will return a null value in place of their respective data."

So a consumer replaying its history after a crash can receive a message ID whose fields are nil, and code that assumes a payload will throw. And "we cap the stream with MAXLEN, so memory is bounded" is false whenever consumers are failing — the entries go away, the PEL references do not.

Redis 8.2 addressed this with a reference policy. It was added as an option to XADD and XTRIM, and shipped alongside two new commands, XDELEX and XACKDEL. Plain XDEL is unchanged and still leaves references behind — which is precisely why XDELEX exists as a separate command rather than a new flag:

PolicyBehaviourUse when
KEEPREFRemoves the entry, preserves PEL references. The default, and the pre-8.2 behaviourYou want backward compatibility
DELREFRemoves the entry and every reference to it from all groups' PELsCleaning up a leaked PEL, or making trimming genuinely bounded
ACKEDOnly removes entries acknowledged by every consumer groupYou would rather retain data than drop unprocessed work
# both require Redis 8.2 or later
XTRIM orders MAXLEN ~ 10000 DELREF
XDELEX orders DELREF IDS 1 1730900000000-0

Mind where the policy token goes — it differs by command. In XTRIM it comes last, after the threshold and after any LIMIT. In XADD and XDELEX it comes early, right after the key. And XDELEX requires an explicit count of the IDs that follow (the 1 above), which is easy to miss.

One sharp edge on ACKED. It is safer, not stricter — and it does not guarantee your cap. The documentation warns that "if the number of referenced entries is larger than MAXLEN, trimming will still stop at the limit." Unacknowledged entries block eviction, so adopting ACKED and then letting a consumer group fall behind grows the stream straight past the cap you configured. If memory is the harder constraint, use DELREF and accept that lagging consumers lose entries.

None of these options exist on Valkey, where the only automatic cleanup for dangling PEL references remains XAUTOCLAIM's third reply element.

What Redis 8.x Added That Valkey Does Not Have

Most writing about Redis Streams describes the 5.0 feature set. Since Redis 8.0 the command surface has moved substantially, and Valkey has not followed. Comparing Redis 8.10 against Valkey 9.1.1:

CapabilityCommand or optionRedisValkey 9.1.1
Delete with reference policyXDELEX, XACKDEL8.2
Reference policies on add and trimKEEPREF / DELREF / ACKED8.2
Claim and read in one callXREADGROUP ... CLAIM8.4
Producer idempotencyXADD IDMP / IDMPAUTO, XCFGSET8.6
Explicit negative acknowledgmentXNACK8.8
Reply size capsXREAD / XREADGROUP MAXCOUNT, MAXSIZE8.10

Valkey 9.0 and 9.1 shipped no new stream commands at all; 9.1's only stream change is a performance optimisation making XRANGE and XREVRANGE up to 30% faster. The divergence runs one way — there is no Valkey stream command that Redis lacks.

XNACK: Rejecting a Message on Purpose

Before Redis 8.8, a consumer that could not process a message had one option: do nothing and wait out the idle timeout. XNACK releases a pending message back to the group immediately, in three modes that differ in what they do to the delivery counter:

  • SILENT — decrements the counter, undoing the delivery. For graceful shutdown, where the message was never really attempted.
  • FAIL — leaves the counter unchanged. A genuine failed attempt.
  • FATAL — sets the counter to its maximum, marking the message permanently failed. This is how you flag a poison message for a dead letter queue without moving it anywhere.

A released message is marked unowned, given a delivery time of 0, and placed at the head of the PEL — making it immediately claimable regardless of min-idle-time, rather than waiting out a timeout designed to detect crashes.

One caveat before designing around it: XNACK is currently Redis Open Source only. Its compatibility table marks it unsupported on Redis Software and Redis Cloud alike, in both standard and Active-Active configurations.

Idempotent Production, and What It Is Not

Redis 8.6 added producer-side deduplication. Supply a producer ID and an idempotency ID, and if that pair has been seen before, XADD returns the ID of the original entry instead of writing a duplicate:

# requires Redis 8.6 or later; the stream must already exist for XCFGSET
XCFGSET orders IDMP-DURATION 300 IDMP-MAXSIZE 1000
XADD orders IDMP checkout-svc order-4711 * customer 4711 sku A-22

Redis calls this "at-most-once production" — not exactly-once, and the distinction is worth holding onto. The dedupe window is small by default: IDMP-DURATION is 100 seconds and IDMP-MAXSIZE is 100 IDs per producer, with capacity taking precedence over time. There is no atomic consume-process-produce and no consumer offset inside a transaction. What you get is producer retry deduplication, which combined with at-least-once delivery and idempotent consumers is a good end-to-end story — just not the one Kafka transactions tell.

Two operational notes: a producer must reuse the same producer ID across restarts, and calling XCFGSET with different values clears the key's existing idempotency map.

Delivery Guarantees: What Streams Do and Do Not Promise

Redis Streams are at-least-once, and honestly so. Consumer group state — including the full PEL — is replicated to replicas and written to AOF and RDB, so a promoted replica knows what was pending and a restarted server restores it.

Loss enters on the durability axis, not the delivery axis, in three documented places:

  • fsync policy. The default appendfsync everysec can lose roughly a second of XADDs if the host dies. Persistence settings decide this, not stream settings.
  • Asynchronous replication. An acknowledged XADD may not have reached any replica when the primary fails. WAIT narrows this window.
  • Best-effort failover. Redis documents that failover "may promote a replica that lacks some data" under certain conditions — which is why WAIT narrows the window but cannot close it. This is split-brain territory.

The important nuance: these three lose the entries themselves, which silently shrinks every group's backlog. The PEL cannot help, because the entries were never durable in the first place. That is a data-loss problem, not a redelivery problem, and no amount of consumer-side retry logic addresses it.

Observability: XINFO, Lag and Stuck Messages

Four commands cover almost everything worth alerting on:

XLEN orders
XINFO GROUPS orders
XINFO CONSUMERS orders workers
XPENDING orders workers

XINFO GROUPS gives each group's lag — entries still waiting to be delivered — and pel-count. Climbing lag means consumers are too slow; climbing pel-count while lag stays flat means consumers are receiving work and not acknowledging it, a different problem with a different fix. lag can be NULL when Redis cannot determine it. Redis 8.8 added nacked-count for the NACKed portion of the PEL.

For stuck messages, the extended form of XPENDING filters by idle time and returns the owning consumer and delivery counter per entry — the query behind "alert me about anything unacknowledged for more than five minutes":

XPENDING orders workers IDLE 300000 - + 10

One trap for monitoring older than Redis 7.2: the XINFO STREAM documentation notes that "before Redis 7.2.0, seen-time used to denote the last successful interaction." It now means the last attempted interaction, with active-time carrying the successful one. Dashboards written against the old meaning kept working and quietly changed what they measured. Use active-time for liveness.

XINFO STREAM ... FULL dumps groups, consumers and PEL contents for forensics. It is expensive on a large stream — use it during an incident, not on a scrape interval.

Redis Streams vs. Kafka

The structural difference is smaller than people expect and matters more than they expect. Redis's own documentation puts it well: Kafka partitions are closer to "using N different Redis keys," while a Redis consumer group is "a server-side load balancing system of messages from a given stream to N different consumers."

Consequences follow directly. Redis has no partition ceiling, so consumers can be added freely and a failed one simply stops receiving work — there is no rebalance protocol because there is no assignment to rebalance. But one stream read by N consumers loses per-key ordering, because a faster consumer may finish entry 4 before a slower one finishes entry 3. Kafka preserves order per partition; Redis preserves it only per stream, which in practice means one consumer.

Redis's per-message PEL is finer-grained than Kafka's offsets, though — Kafka cannot express "message 7 failed, retry it, leave 8 and 9 committed" without a dead-letter topic. Our full Redis Streams vs. Kafka comparison covers retention, durability and operational trade-offs in depth.

Redis Streams in Java With Redisson

Redisson exposes streams through RStream, with a builder-style argument API rather than raw command strings. A producer:

RStream<String, String> stream = redisson.getStream("orders");

StreamMessageId id = stream.add(
    StreamAddArgs.entries("customer", "4711", "sku", "A-22")
                 .trimNonStrict()      // the ~ form
                 .maxLen(10_000)
                 .noLimit());

Watch the naming here. trimNonStrict() is the approximate form that sends ~; trim() is the exact one. That reads backwards to most developers, who assume the plain method is the cheap default. It is not — trimNonStrict() is the one you almost always want.

A consumer with group creation, reading and acknowledgment:

try {
    stream.createGroup(StreamCreateGroupArgs.name("workers")
                                            .makeStream()
                                            .id(StreamMessageId.NEWEST));
} catch (RedisException e) {
    // -BUSYGROUP: the group already exists, which is the normal case on restart
}

Map<StreamMessageId, Map<String, String>> messages =
    stream.readGroup("workers", "consumer-1",
        StreamReadGroupArgs.neverDelivered()
                           .count(10)
                           .timeout(Duration.ofSeconds(5)));

for (Map.Entry<StreamMessageId, Map<String, String>> message : messages.entrySet()) {
    process(message.getValue());
    stream.ack("workers", message.getKey());
}

Recovering another consumer's abandoned work — the claim loop the PEL section described:

AutoClaimResult<String, String> claimed = stream.autoClaim(
    "workers", "consumer-2", 60, TimeUnit.SECONDS, StreamMessageId.MIN, 100);

Redisson tracks the Redis 8.x additions with version-gated methods. StreamReferencesArgs exposes keepReferences(), removeReferences() and removeAcknowledgedOnly() for the 8.2 policies; StreamAddArgs.idempotentProducerId() maps to 8.6 idempotency; and nack(StreamNackArgs.group("workers", StreamNackMode.FATAL) ...) maps to 8.8's XNACK. Each javadoc states its minimum Redis version, so the compiler will not protect you from targeting a server that is too old — check before deploying.

When You Do Not Want to Build the Plumbing

RStream is a faithful wrapper over the stream primitives, which means the claim loop, retry counting, dead-lettering and trimming policy remain yours to write and operate. Two alternatives exist, depending on how much of that you want to own.

RReliablePubSubTopic adds automatic redelivery after a visibility timeout, a dead letter topic, negative acknowledgment, deduplication, seek-by-timestamp and configurable synchronous replication. Most usefully given the ordering limitation above, it supports message grouping: messages sharing a group ID always route to the same consumer, restoring the per-key ordering raw consumer groups cannot provide. The Reliable PubSub vs. Redis Streams comparison breaks this down feature by feature, and Redis Streams for Java covers the RStream API in more depth.

Using Redis Streams as an Event Store

Redis's own documentation lists "Event sourcing (e.g., tracking user actions, clicks, etc.)" among the use cases for streams, and the fit looks obvious: an append-only log, immutable time-ordered entries, and replay over any range. In event sourcing the log is the system of record — state is derived by replaying events rather than stored and overwritten.

Streams give you three of the four things that architecture needs, and one of them they give you better than most purpose-built event stores. The fourth is a hard constraint that decides whether any of this is viable for your workload, and it is the part most write-ups skip.

The Stream ID Is Your Aggregate Version

The first decision is one stream per aggregate — account:42, order:7f3a9c1e — rather than one global events stream. That is the opposite of the work-queue layout the rest of this page describes, and it is what makes the next trick work.

Event sourcing needs optimistic concurrency: append these events only if nobody else has written to this aggregate since I read it. Most event stores implement that as an explicit expectedVersion parameter. Redis gives it to you for free, because XADD already rejects any ID that is not strictly greater than the current top entry. Put the aggregate version in the sequence field and the monotonicity check becomes the concurrency check:

XADD account:42 1-1 type Opened     amount 0     # version 1
XADD account:42 1-2 type Deposited  amount 100   # version 2

# a second writer that also read version 1 tries to append version 2:
XADD account:42 1-2 type Withdrew   amount 50
(error) ERR The ID specified in XADD is equal or smaller than the target stream top item

That is a genuine server-side compare-and-append — no WATCH, no MULTI, no Lua, one round trip. The losing writer gets an error rather than a silent overwrite, reloads the aggregate and retries. It also makes the append idempotent under client retries: replaying the same command with the same version fails the same way instead of duplicating the event.

Two boundaries are worth knowing before you build on it. 0-0 is rejected outright — "ERR The ID specified in XADD must be greater than 0-0" — so your version numbering starts at 1. That is the only forbidden ID: any constant works in the millisecond field, 0 included, because 0-1 is perfectly valid. And the sequence field is an unsigned 64-bit integer, so the ceiling is 18,446,744,073,709,551,615 versions per aggregate. Neither is a real limit; both produce confusing errors if you discover them in production.

In Java, the explicit-ID append is a separate overload — and note it returns void, not the StreamMessageId that the auto-generating form returns, because you already know the ID:

RStream<String, String> stream = redisson.getStream("account:42");

long expectedVersion = 2;   // read during aggregate load

try {
    stream.add(new StreamMessageId(1, expectedVersion),
               StreamAddArgs.entries("type", "Deposited", "amount", "100"));
} catch (RedisException e) {
    // someone else appended first — reload the aggregate and retry
}

Replaying an Aggregate, and When to Snapshot

Rebuilding state is XRANGE over the whole stream, folded through your event handlers. For long-lived aggregates that gets expensive, and the standard remedy applies unchanged: snapshot the rehydrated state into a separate key, record the version it was built at, and replay only the events after it.

Paging the tail uses the exclusive range prefix so the snapshot's own version is not re-applied:

XRANGE account:42 (1-2 + COUNT 500
Snapshot snap = redisson.<Snapshot>getBucket("account:42:snapshot").get();
long from = (snap == null) ? 0 : snap.version();

Map<StreamMessageId, Map<String, String>> tail = stream.range(
    StreamRangeArgs.startIdExclusive(new StreamMessageId(1, from))
                   .endId(StreamMessageId.MAX)
                   .count(500));

Account account = (snap == null) ? Account.empty() : snap.state();
tail.values().forEach(account::apply);

The snapshot write and the event append are not atomic, and that is fine here — which is worth stating because it is the one place in this architecture where the dual-write problem does not apply. A snapshot is a cache derived from the log, never a source of truth. If it is stale, or missing, or was written and then lost, replay produces the same state from a bit further back. The only cost is latency.

The Constraint That Decides It: the Log Lives in RAM

Here is the part that determines whether any of the above matters for your system. An event-sourced log is retained forever by definition — you cannot delete the events and still claim to derive state from them. Redis holds its dataset in memory. So the entire history has to fit in RAM, permanently, and grow without bound.

Measured on Redis 7.0.15 with a 155-byte event — a 140-byte JSON body plus its field names — at the default stream-node-max-entries 100, using MEMORY USAGE over 100,000 entries:

Events retainedMemoryVerdict
1 million~175 MBComfortable
10 million~1.7 GBFine on a normal instance
100 million~17 GBNow a sizing decision
1 billion~171 GBA cluster whose only job is history

Streams are efficient rather than compact: 183 bytes stored for a 155-byte event is roughly 28 bytes of overhead per entry, because listpack-packed macro nodes amortise most of the per-entry cost. Measure your own payloads before sizing anything — and treat the table as an upper bound, since the used_memory delta for the same workload comes out about 6% lower than MEMORY USAGE reports. Efficient is not the same as bounded.

And the one lever Redis gives you for stream memory is the wrong lever. Trimming removes entries from the head. The XTRIM documentation says of MAXLEN that "Redis enforces this by removing the oldest entries - that is, the entries with the lowest stream IDs - so that only the newest entries are kept." MINID is the same rule expressed as an ID floor — it "evicts entries with IDs lower than the specified threshold." Both delete from the front; neither can delete from the middle or the end.

The oldest events are precisely the ones an event store cannot lose. They are the aggregate's creation, the opening balance, the beginning of the audit trail. MAXLEN on an event-sourced stream is not retention management; it is deleting your source of truth from the front while the application keeps reporting healthy.

That leaves two honest configurations, and it is worth choosing between them deliberately rather than discovering the choice later:

  • Never trim. The log is complete and the architecture is intact. You are committing to holding full history in RAM forever, and to a memory graph that only goes up. Viable when aggregates are numerous but individually short — carts, sessions, order lifecycles — and archived out to cold storage when they close.
  • Snapshot, then MINID-trim below the snapshot. Memory becomes bounded and rebuild-from-zero stops working. You keep current state and recent history; you give up full auditability, temporal queries and the ability to build a new projection from the beginning — which is half the reason to event-source in the first place. Say out loud that this is what you are trading, because the code will not tell you. The concurrency control survives trimming intact, at least: XADD compares against the stream's top entry, not its bottom, so evicting old versions never lets a stale writer back in.

If the retention requirement is regulatory, neither option is a Redis answer. Use a durable event store or a log with tiered storage, and use Redis for what it is unmatched at here — serving the read models.

What Streams Do Not Give You

Three gaps remain after the memory question, and none of them is fatal on its own.

Immutability is a convention, not a guarantee. XDEL removes an entry from the middle of a stream and leaves the surrounding IDs intact, so a log can be rewritten by any client holding a connection. Purpose-built event stores refuse this at the API. If tamper-evidence is part of why you chose event sourcing, it has to come from elsewhere — ACLs restricting XDEL, or hash-chaining each event to its predecessor in the payload.

There is no ordering across aggregates. Stream-per-aggregate means N keys, which in Redis Cluster land in different hash slots on different nodes — good for write throughput, and it means no global sequence exists. A read model that spans aggregates needs its own stream, and writing both the aggregate stream and the global stream is exactly the dual-write problem. Hash tags force related aggregates into one slot when a bounded context genuinely needs joint ordering, at the cost of concentrating their load on one node.

Projections are entirely yours. Consumer groups will deliver events to a projector at-least-once, but nothing tracks projection lag as a first-class concept, rebuilds a projection on schema change, or manages event versioning. The idempotency requirement on every handler is the same as for any at-least-once consumer, and the stream processing concerns apply unchanged.

The summary that matters: Redis Streams are a strong event log and a weak event store. The append path, the concurrency control and the replay primitives are genuinely good — better, in the case of version-as-sequence-ID, than the explicit-parameter approach most stores expose. What is missing is durability economics. Choose this when aggregate lifetimes are bounded, when full history is an operational convenience rather than a compliance obligation, and when Redis is already in the stack and the alternative is running a second one. Choose otherwise when the log must outlive your memory budget.

Redis Streams: Frequently Asked Questions

Can You Use Redis Streams for Event Sourcing?

Yes, with one significant constraint. Streams provide the append-only log, immutable time-ordered entries, range replay and — through XADD's rejection of non-increasing IDs — optimistic concurrency control. What they do not provide is durability economics: an event-sourced log is retained permanently, and Redis holds its dataset in memory, so the full history must fit in RAM forever. Trimming is not an escape, because MAXLEN and MINID both evict the oldest entries — the ones an event store least can afford to lose. Streams suit event sourcing when aggregate lifetimes are bounded or snapshots allow trimming below a known version, and not when full history is a compliance requirement.

How Do You Implement Optimistic Concurrency With Redis Streams?

Use one stream per aggregate and put the aggregate's expected version in the sequence half of the entry ID — XADD account:42 1-7 ... to append version 7. Because XADD rejects any ID equal to or smaller than the stream's current top entry, a second writer holding a stale version receives "ERR The ID specified in XADD is equal or smaller than the target stream top item" instead of overwriting. This is a server-side compare-and-append in a single round trip, with no WATCH, MULTI or Lua script. Note that 0-0 is rejected, so versions start at 1.

What Is a Consumer Group in Redis?

A named cursor plus delivery bookkeeping attached to a stream. The group holds one position in the log and the server hands each new entry to exactly one member — but unlike popping from a list, it records what it handed out, to whom, and when, in the pending entries list. That record is what makes at-least-once delivery and crash recovery possible. Create one with XGROUP CREATE, read with XREADGROUP ... >, and confirm with XACK. Several groups can read the same stream, each keeping its own independent position — which is how one set of events drives several unrelated consumers.

What Is the Difference Between Redis and Redis Streams?

Redis is the database; a stream is one of the data types it stores, alongside strings, hashes, lists, sets and sorted sets. A stream is an append-only log of time-ordered entries that supports replay and consumer groups. It is the only Redis type that tracks which readers have consumed which entries, which is what makes it suitable for event logs and work queues.

What Is the Difference Between Redis Streams and Pub/Sub?

Pub/Sub delivers a message only to clients connected at that moment and retains nothing — an offline subscriber never sees it. A stream stores entries, so consumers can read messages published while they were down, replay history from any point, and acknowledge each message individually. The trade-off is memory: a stream grows until you trim it, while Pub/Sub retains nothing.

What Version of Redis Introduced Streams?

Redis 5.0, released in October 2018. The command set has grown considerably since: Redis 8.2 added XDELEX and XACKDEL, 8.4 added the CLAIM option to XREADGROUP, 8.6 added producer idempotency, 8.8 added XNACK, and 8.10 added reply size caps. Valkey implements the original 5.0 through 7.0 command set but none of the Redis 8.x additions.

Are Redis Streams a Replacement for Kafka?

For many workloads, yes — particularly when Redis is already deployed and the alternative is running a second cluster. The main differences are that a single Redis stream is not automatically partitioned across instances, and that a consumer group loses per-key ordering when several consumers read the same stream. Kafka's partition model preserves ordering while scaling consumers; Redis trades that for simpler operations and no partition ceiling.

What Is the Difference Between a Redis Consumer Group and a Kafka Consumer Group?

Kafka assigns partitions to consumers, so parallelism is capped by the partition count and adding or losing a consumer triggers a rebalance. Redis has no partitions and no rebalance protocol — the server load-balances entries across whoever is currently reading, so consumers can be added freely and a failed one simply stops receiving work. The trade-off is ordering: Kafka preserves it per partition, while a Redis stream read by several consumers loses per-key order, because a faster consumer may finish entry 4 before a slower one finishes entry 3. Redis also tracks state per message rather than per offset, so it can express "message 7 failed, retry it, leave 8 and 9 acknowledged" — which Kafka needs a dead-letter topic to approximate.

Do Redis Streams Guarantee Message Delivery?

Redis Streams provide at-least-once delivery. Consumer group state, including the pending entries list, is replicated and persisted, so a consumer that crashes mid-processing can recover its unacknowledged messages. Entries themselves can still be lost to the fsync policy, asynchronous replication or a best-effort failover — which is a durability limit rather than a delivery limit, and no consumer retry logic can compensate for it.

What Happens to Messages if a Consumer Crashes?

They stay in the group's pending entries list, still owned by the dead consumer, with no timeout and no automatic reassignment. Recovery is the application's responsibility: another consumer must call XAUTOCLAIM or XCLAIM to take ownership, or from Redis 8.4 use XREADGROUP with the CLAIM option to reclaim and read in one call. Without one of these, the messages remain pending indefinitely.

How Do You Stop a Redis Stream From Growing Forever?

Cap it at write time with XADD ... MAXLEN ~ 10000, or by age with MINID, or run XTRIM on a schedule. Two caveats: approximate trimming with ~ can leave a few tens of entries above the threshold because it will not split a macro node, and trimming does not remove pending entries list references unless you add the DELREF option introduced in Redis 8.2.

Do Valkey Streams Support the Same Commands as Redis?

Valkey implements the core stream commands with identical semantics — XADD, XREAD, XREADGROUP, XACK, XAUTOCLAIM, XTRIM and the rest. It does not implement the Redis 8.x additions: XDELEX, XACKDEL, XNACK, the KEEPREF/DELREF/ACKED reference policies, XREADGROUP CLAIM, producer idempotency, or reply size caps. As of Valkey 9.1.1 there is no Valkey stream feature that Redis lacks.

Similar terms