Redis Streams in Java: XADD, XRANGE, XREAD, XPENDING and XTRIM With Redisson

Last updated
August 10, 2026

Five commands do most of the work on a Redis stream. XADD appends, XRANGE replays a closed interval, XREAD tails for new arrivals, XPENDING reports what was delivered but never acknowledged, and XTRIM keeps the whole thing from eating your heap. Learn those five and most of the rest of the stream command set is variation on them.

This is the Java view of each: the RStream method that maps to it, the argument builder it takes, and the mistakes that compile cleanly and then fail in production. Every snippet here was compiled against Redisson 4.7.0. Valkey runs the core of all five identically, and the handful of Redis-only options are flagged as they come up.

Getting a Stream Object

Add the dependency:

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>4.7.0</version>
</dependency>

Then obtain the stream:

Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redisson = Redisson.create(config);

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

The two type parameters trip people who expect them to describe the message. They do not. An entry is a flat set of field-value pairs rather than one serialized object, so K types the field names and V the values — much closer to a hash than to a byte array. Both pass through Redisson's codec, so RStream<String, OrderEvent> works as readily as RStream<String, String>; see data serialization codecs for the choice, and How to Use Redis (or Valkey) in Java for cluster, Sentinel and TLS setups.

One structural quirk to know before you write cleanup code: a stream key outlives its contents. Unlike a list or a set, which vanish when their last element goes, a stream persists at length zero whether or not a consumer group has ever existed on it. So RStream.isExists() answers "is there a stream here", never "is there anything in it" — use size() for that. Consumer groups live on the key and survive with it, which is also why delete() discards every group along with the data.

XADD: Appending Entries and Capping Growth

XADD key [NOMKSTREAM] [KEEPREF | DELREF | ACKED]
  [IDMPAUTO producer-id | IDMP producer-id idempotent-id]
  [<MAXLEN | MINID> [= | ~] threshold [LIMIT count]] <* | id>
  field value [field value ...]

Redisson always uses the server-generated * form unless you supply an ID explicitly, and add hands back the ID that was generated:

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

That call appends one entry and caps the stream in the same round trip. The capping half is the part that decides whether this code is still running in six months.

The trimNonStrict() Naming Trap

Redisson offers both trimming modes and the names point the wrong way. trim() is the strict one: it emits MAXLEN 10000 and holds the stream to exactly that length. trimNonStrict() emits MAXLEN ~ 10000. Nearly everyone assumes the unqualified method is the ordinary default — it is the reverse, and the plain method is the expensive path.

Approximate is usually right, because entries are packed into blocks and ~ lets the server stop as soon as the next block is only partly eligible — discarding a whole block is far cheaper than rebuilding one. The XTRIM reference states the trade: after trimming, "the stream may have a few tens of additional entries over the threshold."

So any alert comparing size() against an approximate cap will fire forever on a healthy system. Use trim() when the length is a contract and trimNonStrict() when it is a budget. Block rounding is only half the story on how far above that budget you drift; the other half is LIMIT.

The trailing noLimit() does considerably less than its name suggests.

LIMIT caps how many entries a single approximate trim will evict, and omitting it does not mean unlimited — Redis supplies its own default, derived from the internal block size, per call. noLimit() looks like the way to switch that off and is not: it terminates the builder chain and emits nothing, leaving the server's implicit cap in force. Redisson 4.7.0 cannot send LIMIT 0, the token that would genuinely disable it.

Measured: against a 3,000-entry stream, trimNonStrict(StreamTrimArgs.maxLen(1).noLimit()) removed 1,000 and left 2,000 — the implicit cap, hit long before the threshold. A second identical call removed another 1,000. Strict trimming carries no such default, and trim(StreamTrimArgs.maxLen(1).noLimit()) took the same stream to one entry in a single call.

So capping on write and doing nothing else can leave a busy stream well above its threshold indefinitely. The fix is to keep trimming until the server stops making progress — testing progress, not length. Once the stream is within one block of the threshold an approximate trim removes nothing and returns zero, so a loop waiting for size() to reach the cap never exits, while one watching the return value exits immediately:

// noLimit() does not disable Redis's implicit eviction cap, so one
// approximate trim may not reach the threshold. Loop on progress,
// never on size() - approximate trimming stops short by design.
long removed;
do {
    removed = stream.trimNonStrict(StreamTrimArgs.maxLen(10_000).noLimit());
} while (removed > 0);

entry() vs entries()

A small trap the compiler catches, but only after you have wondered why. StreamAddArgs.entries() is overloaded for two, three, four and five field-value pairs, plus a Map form. There is deliberately no one-pair overload, because that is what entry() is for. A second, quieter trap comes with it: the varargs overloads collect their arguments into a HashMap, so the field order you wrote is not the field order the server stores — even though Redis itself preserves whatever order it receives. If that matters, build a LinkedHashMap and use the Map form:

// one field: entry(), not entries()
stream.add(StreamAddArgs.entry("customer", "4711"));

// two to five fields: entries()
stream.add(StreamAddArgs.entries("customer", "4711", "sku", "A-22"));

// field order matters? pass a LinkedHashMap
Map<String, String> payload = new LinkedHashMap<>();
payload.put("customer", "4711");
payload.put("sku", "A-22");
stream.add(StreamAddArgs.entries(payload));

Explicit IDs

Passing your own ID is useful for replay tooling and migrations, and carries one absolute rule: it must be strictly larger than the current top of the stream, or the command fails with ERR The ID specified in XADD is equal or smaller than the target stream top item. Add noMakeStream() when a missing key should be treated as a bug rather than an invitation to create one:

stream.add(new StreamMessageId(1786100000000L),
        StreamAddArgs.entry("customer", "4711").noMakeStream());

Note how quietly that fails. The explicit-ID overload returns void, so the nil reply telling you the stream was absent is discarded — the call does nothing and the key still is not there. The auto-ID overload is worse: with no ID to convert, Redisson 4.7.0 raises a RedisException wrapping a NullPointerException. Neither gives you a clean value to branch on, and an isExists() check before the write is not the answer — that reintroduces the race NOMKSTREAM exists to remove. Treat the auto-ID exception as the signal, or verify afterwards.

XRANGE and XREVRANGE: Replaying by ID and Time

XRANGE key start end [COUNT count]
XREVRANGE key end start [COUNT count]

Entry IDs start with a millisecond timestamp, which means any question of the form "what happened between 09:00 and 10:00" is answerable as an ID range, with no secondary index and no scan of the whole log:

Map<StreamMessageId, Map<String, String>> page = stream.range(
        StreamRangeArgs.startId(new StreamMessageId(1786100000000L))
                .endId(new StreamMessageId(1786103600000L))
                .count(100));

The CLI sentinels - and + denote the lowest and highest IDs expressible; Redisson names them StreamMessageId.MIN and StreamMessageId.MAX. Watch the argument order. XREVRANGE takes end before start, inverted from XRANGE, and Redisson does not normalise this — it passes startId and endId through in the order the command expects. So inside rangeReversed, startId is the newest bound and endId the oldest, the reverse of what the same builder means in range:

Map<StreamMessageId, Map<String, String>> newest = stream.rangeReversed(
        StreamRangeArgs.startId(StreamMessageId.MAX)
                .endId(StreamMessageId.MIN)
                .count(1));

That is the idiom for "give me the most recent entry", and it is worth saving rather than re-deriving. Written the intuitive way round — startId(MIN).endId(MAX)rangeReversed raises nothing and returns an empty map, which is indistinguishable from an empty stream.

The Sequence-Fill Asymmetry

Here is a rule that yields wrong answers rather than exceptions, which is why it is worth knowing before you need it.

Every ID has two parts, <milliseconds>-<sequence>. Supply only the first and the server invents the second — but it invents a different one at each end of the range. The reference is unambiguous: "XRANGE will auto-complete the start interval with -0 and end interval with -18446744073709551615."

Read that twice, because it is generous rather than restrictive. Handing XRANGE the same millisecond as both bounds returns every entry written during that millisecond, not merely the one at sequence zero. That is the documented idiom for pulling a single millisecond of history.

Now the asymmetry. XREAD refuses to play along: its reference states that "here the sequence part of the ID, if missing, is always interpreted as zero." One incomplete ID, two commands, two meanings. Java hides the discrepancy, because new StreamMessageId(millis) is always sequence zero wherever you put it. That saves you from the surprise and removes the shorthand: from Redisson, a whole-millisecond query means building the upper bound yourself as new StreamMessageId(millis, Long.MAX_VALUE). That is not the true ceiling — the ID space runs to an unsigned 18446744073709551615, which does not fit a Java long — but it is above any sequence number a real stream will reach in one millisecond. Do not try to express the true maximum by passing -1L; it serialises to -1 and the server rejects it.

Paginating Without Re-Reading the Boundary

Both bounds are inclusive, so the obvious "remember the last ID and pass it again" loop re-delivers one entry per page. Redis 6.2 introduced exclusive intervals via the ( prefix — often mis-cited as a 7.0 feature — and Redisson surfaces them as startIdExclusive:

StreamMessageId cursor = StreamMessageId.MIN;
boolean first = true;

while (true) {
    StreamEndIdArgs<StreamRangeArgs> start =
            first ? StreamRangeArgs.startId(cursor)
                  : StreamRangeArgs.startIdExclusive(cursor);

    Map<StreamMessageId, Map<String, String>> batch =
            stream.range(start.endId(StreamMessageId.MAX).count(500));

    if (batch.isEmpty()) {
        break;
    }
    for (Map.Entry<StreamMessageId, Map<String, String>> e : batch.entrySet()) {
        process(e.getValue());
        cursor = e.getKey();
    }
    first = false;
}

On ordering: range hands back a Map, populated as a LinkedHashMap so that iteration follows server order. Copying the result into a HashMap, or fanning it through a parallel stream, throws that away. For an append-only log the sequence is usually the entire point.

XREAD: Tailing a Stream

XREAD [COUNT count] [MAXCOUNT maxcount] [MAXSIZE maxsize]
  [BLOCK milliseconds] STREAMS key [key ...] id [id ...]

Where a consumer group divides work, XREAD distributes it: no cursor is stored, no acknowledgment is expected, and two readers of the same stream both get everything. The ID you pass is the entire contract. StreamMessageId.NEWEST is the CLI's $ and means "nothing that already exists"; StreamMessageId.ALL is 0 and means "start at the beginning"; anything else resumes immediately after that entry. Redis 7.4 added a + form meaning "the most recent entry", which Valkey 9.1.1 does not implement.

StreamMessageId last = StreamMessageId.NEWEST;

while (true) {
    Map<StreamMessageId, Map<String, String>> batch = stream.read(
            StreamReadArgs.greaterThan(last)
                    .count(100)
                    .timeout(Duration.ofSeconds(5)));

    for (Map.Entry<StreamMessageId, Map<String, String>> e : batch.entrySet()) {
        process(e.getValue());
        last = e.getKey();
    }
}

The reassignment of last inside the loop is the whole thing. The reference calls this out as very important, and means it: "you should use the $ ID only for the first call... Later the ID should be the one of the last reported item in the stream, otherwise you could miss all the entries that are added in between." Pass NEWEST on every iteration and every entry written while you were processing the previous batch is gone. Nothing throws, nothing logs, and the gap is invisible until someone reconciles totals. If you take one habit from this article, make it this one.

timeout() is BLOCK. Zero waits forever; anything else returns an empty map when it expires rather than raising, which is why the loop above simply falls through and tries again.

Redis 8.10 added two further caps, MAXCOUNT and MAXSIZE, and they are not duplicates of COUNT. COUNT applies per stream; these bound the reply as a whole, by entry count and by bytes. On a single-stream read that makes MAXSIZE the interesting one — a byte budget for a reply whose per-entry size you do not control. The distinction earns its keep on the multi-stream forms (StreamMultiReadArgs, StreamMultiReadGroupArgs), where one cap covers the combined reply instead of applying to each stream separately. Redisson exposes both from 4.7.0, on the group and non-group builders alike:

Map<StreamMessageId, Map<String, String>> batch = stream.read(
        StreamReadArgs.greaterThan(last)
                .count(100)          // per stream
                .maxCount(500)       // cumulative entries, Redisson 4.7.0+, Redis 8.10+
                .maxSize(1_048_576));// cumulative bytes

A single entry larger than MAXSIZE still comes back, since the budget is only enforced once something has been emitted. Neither option exists on Valkey.

XREADGROUP: The Consumer Group Loop

A group converts the log into a work queue. Membership is tracked server-side, each entry goes to one member, and the server keeps a record of what it handed out until you confirm you are finished with it. A list-based queue can approximate that last part with the RPOPLPUSH processing-list pattern, but you build and reconcile the second key yourself; a group gives you the owner, the idle time and the delivery count for free:

try {
    stream.createGroup(StreamCreateGroupArgs.name("workers")
            .makeStream()
            .id(StreamMessageId.NEWEST));
} catch (RedisException e) {
    if (!String.valueOf(e.getMessage()).startsWith("BUSYGROUP")) {
        throw e;   // WRONGTYPE, an ACL denial, a dead connection - not ours to swallow
    }
    // the group already exists: normal on every restart after the first
}

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>> e : messages.entrySet()) {
    process(e.getValue());
    stream.ack("workers", e.getKey());
}

Three details in there are load-bearing. makeStream() creates the key when it is absent, without which group creation fails against a stream nobody has written to yet. The catch absorbs -BUSYGROUP — which every restart after the first produces, and which is not a failure — while rethrowing everything else, because an unqualified catch here would also swallow a WRONGTYPE or a dead connection. And neverDelivered() is the > ID, meaning entries no member has yet seen.

Recovering After a Crash

One decision in there deserves more thought than it usually gets: id(StreamMessageId.NEWEST) starts the group at the tip, so if the stream already holds entries when the group is first created, every one of them is skipped — silently, and permanently. That is the right default for a queue you are attaching to a live feed. It is the wrong one if the backlog is the work. Pass StreamMessageId.ALL to start from the beginning instead.

Any ID other than > flips the command's meaning entirely. Instead of new work you get your own unacknowledged history, filtered to IDs above the one you supplied. greaterThan() is how that is written — and the ID you seed it with is not the obvious one:

// StreamMessageId.ALL, not MIN - see below
StreamMessageId cursor = StreamMessageId.ALL;

while (true) {
    Map<StreamMessageId, Map<String, String>> backlog = stream.readGroup(
            "workers", "consumer-1",
            StreamReadGroupArgs.greaterThan(cursor).count(100));

    if (backlog.isEmpty()) {
        break;
    }
    for (Map.Entry<StreamMessageId, Map<String, String>> e : backlog.entrySet()) {
        // an empty payload means the entry was trimmed or deleted while it
        // sat in the PEL: the ID survives, the fields do not
        if (!e.getValue().isEmpty()) {
            process(e.getValue());
        }
        stream.ack("workers", e.getKey());
        cursor = e.getKey();
    }
}

Three things to get right here, and only the first fails loudly.

Seed the cursor with ALL, not MIN. The two constants look interchangeable and are not. StreamMessageId.MIN serialises to -, the range sentinel that XRANGE accepts and XREADGROUP rejects outright with ERR Invalid stream ID specified as stream command argument. StreamMessageId.ALL serialises to 0, which is what this call wants. Nothing distinguishes them at compile time. (MIN is correct for autoClaim and for pending-list ranges, both of which do accept - — which is exactly why the habit forms.)

An explicit ID turns off blocking. The XREADGROUP reference notes that with any ID other than >, "BLOCK, NOACK, and CLAIM are ignored." A recovery loop written with a five-second timeout does not pause for five seconds — it returns instantly. That is correct, since you are draining a finite list rather than waiting for arrivals, but a loop that assumes the timeout is throttling it becomes a busy-wait the moment the backlog empties.

The payload can be missing, and it will not look missing. An entry trimmed or deleted while still unacknowledged leaves its ID in the pending list with nothing behind it; the reference confirms Redis "will return a null value in place of their respective data." Redisson does not pass that null through — its decoder turns it into an empty map. So a null check never fires, nothing throws, and unguarded code cheerfully "processes" an entry with no fields. That is a quieter failure than an exception and considerably harder to find. Test with isEmpty(), acknowledge the ID, and move on.

XPENDING: Finding Work That Never Finished

XPENDING key group [[IDLE min-idle-time] start end count [consumer]]

Delivery moves an entry into the group's pending list, where it waits for an acknowledgment alongside the name of whoever took it, how long it has been quiet, and how many times it has been handed out.

The property that catches teams migrating from a broker: entries in it never expire. There is no visibility timeout. A worker that dies holding ten messages holds them permanently — not requeued, not released, not offered to anyone else. min-idle-time looks like a server-side timer and is not; it is a filter you apply when you come looking. Redis states the obligation directly: a consumer "has to inspect the list of pending messages, and will have to claim specific messages using a special command, otherwise the server will leave the messages pending forever and assigned to the old consumer."

That inspection is XPENDING, and it answers in two different shapes depending on how you call it.

The Summary Form

Key and group only. You get a count, the oldest and newest pending IDs, and a per-consumer tally. It is cheap enough to poll and it is what belongs on a dashboard:

PendingResult summary = stream.getPendingInfo("workers");

long total = summary.getTotal();               // how many unacknowledged
StreamMessageId oldest = summary.getLowestId();
Map<String, Long> perConsumer = summary.getConsumerNames();

The Extended Form

Add a range and a count and the reply becomes one row per message: ID, current owner, milliseconds since it was last handed out, and how many times that has happened. With the IDLE filter — added in Redis 6.2 — this is the query behind any alert phrased as "anything unacknowledged for more than five minutes":

List<PendingEntry> stuck = stream.listPending(
        StreamPendingRangeArgs.groupName("workers")
                .startId(StreamMessageId.MIN)
                .endId(StreamMessageId.MAX)
                .count(100)
                .idleTime(Duration.ofMinutes(5)));

for (PendingEntry entry : stuck) {
    if (entry.getDeliveryCount() > 5) {
        deadLetter(entry.getId());
        stream.ack("workers", entry.getId());   // XPENDING is read-only:
    }                                           // without this it stays pending forever
}

The acknowledgment after deadLetter() is not optional. XPENDING only reports; it changes nothing. Route a message elsewhere without acknowledging it and it stays in the pending list, owned by the same consumer, for good — you will have built the leak this section exists to warn about, once per poison message.

The delivery count is what separates a slow message from a broken one, and it is the input to any dead letter queue you build: five deliveries and zero acknowledgments is far more likely a payload your consumer cannot handle than a transient stall — though a dependency that has been down all morning produces the same number, so treat it as a strong signal rather than proof.

Two API notes. The positional listPending overloads are deprecated in favour of the StreamPendingRangeArgs form above; the similarly named pendingRange is not deprecated and is not the same call — it returns the message bodies rather than the pending metadata, so reach for it when you want to see what the stuck work actually contains. And the builder makes count() mandatory rather than optional. The compiler will not let you omit it, which is deliberate: an unbounded pending scan against a badly lagging group is not a query you want to issue by accident.

Worth knowing before you build tooling on it: this command is flagged read-only and, per the documentation, "is always safe to call and will not change ownership of any message." Claiming is the opposite — it resets idle time and bumps the delivery count. Look with XPENDING, act with autoClaim, and your counters keep meaning what you think they mean.

Claiming Abandoned Work

Once you have decided something is genuinely stranded, autoClaim walks the pending list from a cursor and transfers ownership of anything quiet for longer than the threshold:

// XAUTOCLAIM signals "sweep complete" with the literal ID 0-0.
// StreamMessageId.ALL is a sentinel, not that value - it will never match.
StreamMessageId sweepComplete = new StreamMessageId(0, 0);
StreamMessageId cursor = StreamMessageId.MIN;

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

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

    // IDs that no longer exist in the stream, purged from the PEL as a side effect
    log(claimed.getDeletedIds());

    cursor = claimed.getNextId();
} while (!sweepComplete.equals(cursor));

Two things about that loop. The cursor matters — autoClaim sweeps a page at a time and hands back where to resume, so a single call is not a sweep. And the reply has carried three elements since Redis 7.0, the third of which is routinely ignored: getDeletedIds() lists entries that were referenced in the pending list but no longer exist in the stream, dropped by the scan as it passes. Acknowledging clears a reference too, as does claiming with XCLAIM since Redis 7.0, and destroying the consumer or the group takes the lot — but on Valkey and on Redis before 8.2, this is the only path that finds and clears them in bulk without you first knowing which IDs to name. getNextId() returns 0-0 once the sweep is complete, which is what the loop condition tests — and testing it correctly takes more care than it looks. StreamMessageId.ALL serialises to 0 and reads like the obvious constant to compare against, but it is a sentinel: internally it carries -1, not zero, so StreamMessageId.ALL.equals(cursor) is false even when the sweep has finished. Compare against new StreamMessageId(0, 0) instead. Get this wrong and the loop claims everything on the first few passes and then sweeps an empty pending list forever.

Consumer records are equally permanent. Taking a dead worker's messages does not retire the worker, so deriving consumer names from pod names in Kubernetes accumulates one dead record per pod that ever ran. removeConsumer is XGROUP DELCONSUMER, and its return value — the number of messages that consumer still owned — is worth logging rather than discarding, because anything above zero means you just abandoned work that nobody has picked up.

XTRIM: Retention Without Leaking the Pending List

XTRIM key <MAXLEN | MINID> [= | ~] threshold [LIMIT count] [KEEPREF | DELREF | ACKED]

Capping on write is the cheap path; XTRIM is the deliberate one, for scheduled retention jobs and one-off reclaims. It returns how many entries went:

// approximate: sends XTRIM orders MAXLEN ~ 10000
long removed = stream.trimNonStrict(StreamTrimArgs.maxLen(10_000).noLimit());

// exact: sends XTRIM orders MAXLEN 10000
long removedExact = stream.trim(StreamTrimArgs.maxLen(10_000).noLimit());

// by age: drop everything older than 24 hours
long byAge = stream.trimNonStrict(
        StreamTrimArgs.minId(new StreamMessageId(System.currentTimeMillis() - 86_400_000L))
                .noLimit());

Use maxLen to bound by count and minId to bound by age — the latter works precisely because IDs lead with a timestamp, so "older than 24 hours" is expressible as an ID. Both take the strict and approximate forms, with the same block-boundary behaviour described under XADD.

Trimming Does Not Trim the Pending List

This is the interaction that turns a well-configured cap into an incident, and it went undocumented on both command pages until Redis 8.2 introduced the policies that finally named it. Evicting an entry that some consumer is still holding removes the data and leaves the reference — the same orphaned ID that forced the isEmpty() guard into the recovery loop earlier. "The stream is capped, so memory is bounded" therefore stops being true exactly when consumers start failing, which is the moment you were relying on it.

Redis 8.2 introduced reference policies to close this, and Redisson attaches them to the same chain:

long removed = stream.trimNonStrict(
        StreamTrimArgs.maxLen(10_000)
                .removeReferences()   // DELREF, requires Redis 8.2
                .noLimit());
Config methodSendsEffect
keepReferences()KEEPREFDrops the data, keeps the reference. What you get by default, and what every version before 8.2 did
removeReferences()DELREFDrops the data and purges the reference from every group at once
removeAcknowledgedOnly()ACKEDRefuses to evict anything a group has not finished with

removeAcknowledgedOnly() reads like the conservative choice and is worth understanding before you pick it, because it protects your data at the expense of your cap. The reference notes, in a sentence that repays careful reading, that "if the number of referenced entries is larger than MAXLEN, trimming will still stop at the limit." Outstanding work is not evictable, so a group that falls behind will push the stream past the ceiling you set — and the worse the lag, the further past. Pick removeAcknowledgedOnly() when losing unprocessed entries is the greater risk, and removeReferences() when running out of memory is. There is no option that gives you both. None of the three exists on Valkey.

One Combination the Compiler Allows and the Server Rejects

The builder will happily attach a limit to a strict trim:

// compiles, then fails at runtime:
// ERR syntax error, LIMIT cannot be used without the special ~ option
stream.trim(StreamTrimArgs.maxLen(10_000).limit(500));

Redis declines LIMIT unless the approximate operator is present, with ERR syntax error, LIMIT cannot be used without the special ~ option. The reference never states the restriction outright; its standalone LIMIT entry reads as though the option were independent of the operator, and even documents the LIMIT 0 that Redisson cannot send. The error text lives in the server source rather than the docs, so nothing warns you, least of all the type system. If you want bounded trimming work you want trimNonStrict(); pair trim() with noLimit() and nothing else.

Which Read Command Should You Use?

XRANGEXREADXREADGROUP
Redisson methodrangereadreadGroup
Server remembers your positionNoNoYes, per group
Two readers, same entryBothBothOne only
Waits for new entriesNoYesYes, with > only
AcknowledgmentNoneNonePer message
Survives a consumer crashN/ANoYes, via the pending list
Reach for it whenReplaying, auditing, backfillingFanout and live tailingDividing work

In one line: range for history, read when everyone should see everything, readGroup when work must be split and must not be lost. Only the third gives you at-least-once delivery — and only because you wrote the recovery loop that makes it true.

Four Things That Only Work on Redis

Most material on streams still describes the Redis 5.0 command set. The surface has moved since 8.0 and Valkey has not tracked it — its 9.0 and 9.1 releases added no stream commands at all. Restricted to the commands used in this article, Redis 8.10 against Valkey 9.1.1:

CapabilityRedisValkey 9.1.1
+ as an XREAD ID (last entry)7.4Absent
KEEPREF / DELREF / ACKED on XADD and XTRIM8.2Absent
XREADGROUP ... CLAIM — claim and read in one call8.4Absent
MAXCOUNT / MAXSIZE reply caps8.10Absent

Nothing in the table runs the other way, and as of Valkey 9.1.1 there is no stream feature Valkey has that Redis lacks. Redisson's javadoc records a minimum server version on most of the affected methods — maxCount and maxSize say 8.10, the reference-policy methods say 8.2 — but not on all of them: StreamReadGroupArgs.claim(Duration), the 8.4 feature in the table above, carries no version note at all. Nothing in the type system distinguishes a Redis 8.10 target from a Valkey 9.1 one either, so the failure arrives at runtime as an unrecognised argument. Pin the server version in your integration tests; it is the only check that actually runs.

Where RStream Stops

Everything above works, and everything above is also an inventory of what you now maintain. The claim sweep, the retry ceiling, the poison-message rule, the dead-letter destination, the trimming schedule and the alert on all of it are application code you own. When you want the primitives, that is exactly right. When what you wanted was a reliable queue, it is a lot of surface area to keep correct at three in the morning.

One limitation is not solvable in application code at all. Because assignment happens server-side, a group spreading one stream across several workers gives up ordering: worker A can finish entry 4 while worker B is still on entry 3, and no amount of client logic reimposes the sequence without serialising the consumers and discarding the parallelism you added them for.

Redisson's RReliableTopic sits one level up, is stream-backed, and ships in the Community edition with redelivery handled for you. Reliable PubSub in Redisson PRO goes further again — visibility timeouts, automatic redelivery, a dead letter topic, negative acknowledgment, deduplication, seek by timestamp — and answers the ordering problem head-on with message grouping, which pins every message sharing a group ID to the same consumer. The side-by-side comparison with raw streams is the right place to decide which layer you need.

For the mechanics underneath these commands — ID structure, the pending list, delivery guarantees, and the full Redis 8.x surface — see Redis Streams. If the real question is whether to use a stream at all, Redis Streams vs Kafka covers partitioning, retention and the operational trade-offs.

Frequently Asked Questions

When Should I Use readGroup() Instead of read()?

read keeps no server-side state: every caller receives every entry, so two instances of your service each process the whole stream. readGroup goes through a consumer group, which holds a shared position, gives each entry to exactly one member, and tracks it until acknowledged. Use read for fanout and live tailing; use readGroup whenever work has to be divided across instances and must survive one of them dying.

What Is the Redisson Equivalent of XADD?

RStream.add(StreamAddArgs). Use StreamAddArgs.entry(k, v) for a single field and entries(...) for two to five pairs or a Map; the call returns the generated StreamMessageId. Capping rides on the same chain, so .trimNonStrict().maxLen(10_000).noLimit() produces XADD ... MAXLEN ~ 10000 in one round trip.

How Do I Read a Redis Stream From a Specific Time in Java?

Entry IDs open with a millisecond timestamp, so a time window is just an ID window. Build new StreamMessageId(epochMillis) and hand it to range(StreamRangeArgs.startId(...).endId(...)) for a bounded interval, or to read(StreamReadArgs.greaterThan(...)) to follow the stream forward from that instant. No index is consulted — it is an ordered walk of the log.

Why Does XRANGE Return More Entries Than I Expected?

Because an incomplete ID is completed differently at each end. Given a bare millisecond, XRANGE supplies sequence 0 for the lower bound and the maximum sequence for the upper, so the same timestamp on both sides returns everything written during that millisecond. XREAD does not behave this way — it always supplies zero. From Redisson the point is moot in one direction, since new StreamMessageId(millis) is sequence zero everywhere, so you must build the upper bound explicitly to capture a full millisecond.

Does XPENDING Change a Message's Delivery Count?

No. It is a read-only command that, in the documentation's words, "is always safe to call and will not change ownership of any message." The delivery counter moves when a message is claimed through XCLAIM or XAUTOCLAIM, or re-read through XREADGROUP with an explicit ID. Inspect freely; only claiming has consequences.

How Do I Reclaim Messages From a Crashed Consumer?

Nothing happens on its own, so you have two routes. If the consumer comes back under the same name, have it replay its own history with StreamReadGroupArgs.greaterThan(StreamMessageId.ALL) — note ALL, not MIN, which XREADGROUP rejects — advancing the cursor until the reply is empty, then switch to neverDelivered(). If it is gone for good, another worker calls autoClaim with an idle threshold to take ownership. Skip both and those messages stay assigned to a process that no longer exists, indefinitely.

Which Redisson Method Caps a Stream's Size?

Either StreamAddArgs ... .trimNonStrict().maxLen(n).noLimit() to cap on every write, or trimNonStrict(StreamTrimArgs.maxLen(n).noLimit()) on a schedule; swap maxLen for minId to bound by age instead. Two things to expect. The approximate form settles above the threshold — partly block rounding, mostly because Redis caps how much one call may evict and noLimit() does not lift that cap — so repeat the trim until it reports zero entries removed, never until size() reaches the target, which for an approximate trim it may never do. And eviction leaves pending-list references behind unless you add removeReferences(), which needs Redis 8.2.

Do These Commands Work the Same on Valkey?

The core behaviour of XADD, XRANGE, XREAD, XPENDING, XTRIM, XREADGROUP and XAUTOCLAIM is identical, so ordinary Redisson code ports without change. Four features used above are Redis-only as of Valkey 9.1.1: + as an XREAD ID, the KEEPREF/DELREF/ACKED policies, XREADGROUP ... CLAIM, and the MAXCOUNT/MAXSIZE caps.

Next Steps

That is the working set: five commands, their Redisson methods, and the handful of behaviours that only reveal themselves under load. Where to go next depends on what you are building. Redis Streams covers the underlying mechanics and the complete Redis 8.x command surface. Reliable PubSub vs Redis Streams weighs raw RStream against a managed alternative. Redis Data Structures in Java maps every other Redis type onto its Java object. Kafka Connect and Redis covers the case where a connector, not your code, is writing the stream. The Redisson documentation carries the async, reactive and RxJava forms of everything shown here. And if the recovery loop, dead-lettering and message ordering are work you would rather not own, Redisson PRO provides them — try it for free.