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 via XGROUP 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 one XACK per 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:

  1. Look up the stream orders:stream and confirm it holds a stream value (otherwise return a WRONGTYPE error).
  2. Look up the consumer group order-processors attached 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 for XACK itself).
  3. For each ID given, check whether it is present in the group’s PEL.
  4. If present, remove that entry from the PEL entirely (freeing its consumer/delivery-time/delivery-count bookkeeping) and increment the return counter.
  5. If absent, skip it silently — it contributes nothing to the return value.
  6. 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 id3 call instead of issuing one round trip per message when processing entries in bulk.
  • Periodically inspect XPENDING key group to 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/XAUTOCLAIM can 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 XACK as a delete: if you also need to reclaim stream memory, trim separately with XTRIM or a capped XADD ... MAXLEN, which is a different lesson.

Practice Exercises

  • Create a stream tasks:stream, add three entries with explicit IDs 1-1, 2-1, 3-1, create a group workers, and read all three with XREADGROUP as consumer w1. Acknowledge only the first two, then run XPENDING tasks:stream workers and confirm exactly one entry remains pending.
  • Using the same stream, try to XACK an ID that was never added (e.g. 9-1). Confirm it returns (integer) 0 rather 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 check XLEN before and after calling XACK on 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.
  • XACK only affects the named group’s bookkeeping; it never deletes the entry from the stream itself (XLEN and 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 XACK is a common source of unbounded PEL growth; use XPENDING to monitor for it and XCLAIM/XAUTOCLAIM to recover from crashed consumers.