Acknowledging Messages (XACK)
A Redis Stream consumer group lets multiple consumers split up the work of reading from a stream, and Redis keeps track of which messages each consumer has been handed but hasn’t finished processing yet. XACK is how a consumer tells Redis “I’m done with this one” — it removes one or more entries from a consumer group’s Pending Entries List (PEL) so Redis stops treating them as in-flight work. Without XACK, every message a consumer reads through a group stays pending forever, even after it has been handled successfully, which quietly grows an unbounded backlog.
Overview / How it works
Streams (created with XADD) are append-only logs of entries, each with a unique ID of the form <milliseconds>-<sequence>. A consumer group, created with XGROUP CREATE, is a named cursor over a stream that lets several independent consumers cooperatively read new entries without duplicating work — each entry delivered via XREADGROUP ... STREAMS key > goes to exactly one consumer in the group.
The key detail is what happens the moment a consumer reads an entry with XREADGROUP: Redis does not just hand back the data, it also records the entry in that group’s Pending Entries List — an internal structure that remembers, for every delivered-but-unacknowledged message, which consumer received it, when it was delivered, and how many times it has been delivered. The entry stays in the PEL even after the read call returns; Redis has no way of knowing whether the consumer crashed before finishing the work.
XACK is the other half of that contract. When a consumer finishes processing a message, it calls XACK key group id, and Redis deletes that ID from the group’s PEL. The message itself is not removed from the stream — other consumer groups reading the same stream are unaffected, and the entry still counts toward XLEN. XACK only affects bookkeeping for one specific group.
This matters operationally: a long PEL is a signal that consumers are falling behind or crashing without acknowledging. Tools like XPENDING (to inspect the PEL) and XCLAIM (to hand a stuck message to a different consumer, covered in its own lesson) exist specifically to recover from unacknowledged messages. Because Redis is single-threaded, the bookkeeping update that XACK performs is atomic with respect to every other command — there’s no race where two consumers could both believe they successfully acknowledged, or half-fail, the same ID.
Syntax
XACK key group id [id ...]
key— the name of the stream.group— the name of the consumer group whose PEL should be updated. It must already exist (created viaXGROUP CREATE).id [id ...]— one or more stream entry IDs to acknowledge. You can acknowledge several messages in a single call, which is cheaper than issuing oneXACKper message.
XACK returns an integer: the number of IDs that were actually found in the group’s PEL and removed. IDs that were never delivered to this group, already acknowledged, or simply don’t exist are silently skipped and do not count — and, importantly, this is not an error.
Examples
Example 1: reading through a group populates the PEL
Before acknowledging anything, add two entries, create a consumer group, and read them. Notice that XPENDING (used here in its summary form) immediately shows both IDs as pending for worker-1.
XADD orders:stream 1-1 order_id 1001 amount 250
XADD orders:stream 2-1 order_id 1002 amount 75
XGROUP CREATE orders:stream order-processors 0
XREADGROUP GROUP order-processors worker-1 COUNT 2 STREAMS orders:stream >
XPENDING orders:stream order-processors
Output:
(integer) 1
(integer) 1
OK
1) 1) "orders:stream"
2) 1) 1) "1-1"
2) 1) "order_id"
2) "1001"
3) "amount"
4) "250"
2) 1) "2-1"
2) 1) "order_id"
2) "1002"
3) "amount"
4) "75"
1) (integer) 2
2) "1-1"
3) "2-1"
4) 1) 1) "worker-1"
2) "2"
The two XADD calls report the ID of the entry just written. XGROUP CREATE ... 0 starts the group’s cursor at the beginning of the stream so it will deliver every existing entry. The > in XREADGROUP means “give me only entries never delivered to this group before.” XPENDING‘s summary form reports: total pending count, the lowest and highest pending IDs, and a per-consumer breakdown — here, both entries are pending for worker-1 because nothing has been acknowledged yet.
Example 2: acknowledging one message
Now the consumer finishes processing order 1-1 and acknowledges it. The PEL shrinks to just the second entry, but the stream itself is untouched.
XADD orders:stream 1-1 order_id 1001 amount 250
XADD orders:stream 2-1 order_id 1002 amount 75
XGROUP CREATE orders:stream order-processors 0
XREADGROUP GROUP order-processors worker-1 COUNT 2 STREAMS orders:stream >
XACK orders:stream order-processors 1-1
XPENDING orders:stream order-processors
XLEN orders:stream
Output:
(integer) 1
(integer) 1
OK
1) 1) "orders:stream"
2) 1) 1) "1-1" ...
2) 1) "2-1" ...
(integer) 1
1) (integer) 1
2) "2-1"
3) "2-1"
4) 1) 1) "worker-1"
2) "1"
(integer) 2
XACK returns (integer) 1 — one ID was found in the PEL and removed. XPENDING afterward shows only 2-1 remaining pending. Crucially, XLEN orders:stream still reports 2: acknowledging a message removes it from the group’s bookkeeping only, never from the stream’s actual entries.
Example 3: acknowledging several IDs at once, and re-acknowledging
XADD orders:stream 1-1 order_id 1001 amount 250
XADD orders:stream 2-1 order_id 1002 amount 75
XADD orders:stream 3-1 order_id 1003 amount 40
XGROUP CREATE orders:stream order-processors 0
XREADGROUP GROUP order-processors worker-1 COUNT 3 STREAMS orders:stream >
XACK orders:stream order-processors 1-1 2-1 3-1
XACK orders:stream order-processors 1-1
XPENDING orders:stream order-processors
Output:
(integer) 1
(integer) 1
(integer) 1
OK
1) 1) "orders:stream"
2) 1) ...3 entries...
(integer) 3
(integer) 0
1) (integer) 0
2) (nil)
3) (nil)
4) (nil)
Passing three IDs to a single XACK call acknowledges all three at once and returns (integer) 3. The second XACK, trying to re-acknowledge 1-1, finds nothing left in the PEL for that ID and returns (integer) 0 — not an error, just “zero messages matched.” With the PEL empty, XPENDING‘s summary form reports a count of 0 and (nil) for the ID range and consumer breakdown.
How it works step by step
When you run XACK orders:stream order-processors 2-1, Redis performs, atomically and on the single command-processing thread:
- Look up the stream
orders:streamand confirm it holds a stream value (otherwise return aWRONGTYPEerror). - Look up the consumer group
order-processorsattached to that stream (if it doesn’t exist, treat every ID as unmatched and effectively return 0 for it — no group-not-found error is raised forXACKitself). - For each ID given, check whether it is present in the group’s PEL.
- If present, remove that entry from the PEL entirely (freeing its consumer/delivery-time/delivery-count bookkeeping) and increment the return counter.
- If absent, skip it silently — it contributes nothing to the return value.
- Return the total number of IDs that were actually removed.
Because the whole operation runs as one atomic step, there’s no window where a crash or a concurrent command could leave the PEL partially updated for a multi-ID XACK call.
Time complexity reference
| Command | Time complexity |
|---|---|
XADD |
O(1) per entry appended |
XGROUP CREATE |
O(1) |
XREADGROUP |
O(1) per stream requested, plus the cost of serializing the entries returned |
XACK |
O(1) for each message ID processed |
XPENDING (summary form) |
O(1) |
XPENDING (extended form) |
O(N) where N is the number of pending entries returned |
XCLAIM |
O(log N) where N is the number of entries in the PEL |
Common Mistakes
Mistake 1: calling XACK on a key that isn’t a stream. Every Redis key has exactly one type, and stream commands reject keys holding something else.
SET orders:stream "not-a-stream"
XACK orders:stream order-processors 1-1
Output:
OK
(error) WRONGTYPE Operation against a key holding the wrong kind of value
Fix: only run stream commands against keys you created with XADD, and use TYPE key to check if you’re unsure what a key currently holds.
Mistake 2: forgetting to call XACK after processing. It’s easy to assume that once XREADGROUP returns data, the job is “done” from Redis’s point of view — it isn’t. Every entry read via a group stays in the PEL indefinitely until acknowledged, even if the consumer already wrote the result somewhere else.
XADD notifications:stream 1-1 type email to user1@example.com
XGROUP CREATE notifications:stream mailer 0
XREADGROUP GROUP mailer worker-1 COUNT 1 STREAMS notifications:stream >
XPENDING notifications:stream mailer
Output:
(integer) 1
OK
1) 1) "notifications:stream"
2) 1) 1) "1-1"
2) 1) "type"
2) "email"
3) "to"
4) "user1@example.com"
1) (integer) 1
2) "1-1"
3) "1-1"
4) 1) 1) "worker-1"
2) "1"
Notice the entry is still pending — nothing has acknowledged it. If this consumer never calls XACK (say, it crashes right after reading), the message sits in the PEL forever, growing memory usage and hiding real backlog from monitoring. Fix: always acknowledge as the very last step of successful processing, and use XCLAIM or XAUTOCLAIM to recover entries whose consumer died mid-processing.
Mistake 3: confusing XACK with XDEL. XACK only clears a group’s PEL entry; it does not remove the message from the stream, so other consumer groups (or future readers of the raw stream with XRANGE) still see it. XDEL, by contrast, permanently deletes the entry from the stream for everyone. Don’t reach for XDEL just to mark something as processed — that destroys data other groups may still need.
Best Practices
- Acknowledge a message only after your work for it (writing to a database, sending an email, etc.) has actually succeeded — acknowledging too early loses the message forever if a crash happens right after.
- Batch multiple IDs into one
XACK key group id1 id2 id3call instead of issuing one round trip per message when processing entries in bulk. - Periodically inspect
XPENDING key groupto catch consumers that are reading but not acknowledging — a growing pending count is an early warning sign. - Use a dedicated, stable consumer name per worker process (not a random one per connection) so
XCLAIM/XAUTOCLAIMcan recover that worker’s pending entries if it dies and restarts. - Remember
XACK‘s return value tells you how many IDs actually matched — if it’s lower than expected, some IDs were already acknowledged, never delivered to this group, or mistyped. - Don’t treat
XACKas a delete: if you also need to reclaim stream memory, trim separately withXTRIMor a cappedXADD ... MAXLEN, which is a different lesson.
Practice Exercises
- Create a stream
tasks:stream, add three entries with explicit IDs1-1,2-1,3-1, create a groupworkers, and read all three withXREADGROUPas consumerw1. Acknowledge only the first two, then runXPENDING tasks:stream workersand confirm exactly one entry remains pending. - Using the same stream, try to
XACKan ID that was never added (e.g.9-1). Confirm it returns(integer) 0rather than an error, and explain in your own words why Redis treats that as a normal, non-error outcome. - Add one entry to a fresh stream, create a group, read it with
XREADGROUP, and checkXLENbefore and after callingXACKon it. Confirm the length doesn’t change, and explain what would need to change in your commands if you actually wanted the entry gone from the stream entirely.
Summary
XACK key group id [id ...]removes one or more entry IDs from a consumer group’s Pending Entries List (PEL), marking them as successfully processed.- Entries enter the PEL automatically when read via
XREADGROUP ... STREAMS key >, and stay there until acknowledged — there is no automatic timeout. XACKonly affects the named group’s bookkeeping; it never deletes the entry from the stream itself (XLENand other groups are unaffected).- The return value is the count of IDs actually found and removed; already-acknowledged, never-delivered, or nonexistent IDs are skipped and simply lower that count — never an error.
- Time complexity is O(1) per ID, and it’s atomic thanks to Redis’s single-threaded execution model.
- Failing to call
XACKis a common source of unbounded PEL growth; useXPENDINGto monitor for it andXCLAIM/XAUTOCLAIMto recover from crashed consumers.
