A Mailroom That Never Drops a Letter
A Redis consumer group is that mailroom. Many workers read one stream, each message goes to one worker, and a message is only cleared once a worker confirms it. This tutorial covers that reliable processing, then uses it to build a real-time notification feed.
The Deliver-and-Acknowledge Cycle
A consumer group tracks a Pending Entries List (the PEL). When a worker reads a
new message with >, Redis marks it delivered and puts it in the PEL. The message
stays there until the worker sends XACK. Only then is it considered done.
A message is delivered to one worker and held in the pending list. It is removed only after the worker acknowledges it, so a crash never loses work.
# create the group, starting from new messages ($). MKSTREAM makes the stream if needed
XGROUP CREATE notifications g1 $ MKSTREAM
# a worker reads NEW messages ( > ), up to 10, waiting up to 5s
XREADGROUP GROUP g1 worker-1 COUNT 10 BLOCK 5000 STREAMS notifications >
# after the work is done, acknowledge so it leaves the pending list
XACK notifications g1 1762-0
# see what is still pending for the group
XPENDING notifications g1
A message may be delivered again if a worker fails before XACK. That is
"at-least-once" delivery. So make your work idempotent: running it twice
should be safe. For a notification, check "already sent?" before sending again.
When a Worker Crashes — Claim and Recover
A worker can die mid-message. Its message is still in the pending list, owned by the dead worker,
its idle time growing. Another worker notices and takes it over with XAUTOCLAIM (or
XCLAIM). Nothing is lost.
worker-1 crashed holding 1762-0. It has been idle too long, so worker-2 claims it from the pending list and finishes the job.
# find messages idle longer than 60s and hand them to worker-2
XAUTOCLAIM notifications g1 worker-2 60000 0
# or claim one specific message by ID
XCLAIM notifications g1 worker-2 60000 1762-0
# inspect pending details: how many times each was delivered
XPENDING notifications g1 - + 10
Some messages fail again and again. XPENDING shows a delivery count. If a
message has been tried too many times (say more than 5), stop retrying: copy it to a separate
"dead-letter" stream with XADD, then XACK the original so it does
not block the group.
Building a Real-Time Notification Feed
Now let us use these pieces. A notification feed must do two things at once: store every notification so a user can see it later, and push it live to a user who is online right now. We use a stream for storage and Pub/Sub for the live ping.
One event writes to the user's stream (durable) and fires a live ping. Online users get it instantly; offline users read the stored feed when they return.
XADD it to the target user's stream notif:user:88. This is the durable copy.INCR unread:user:88 so the little red badge shows the right number at once.PUBLISH user:88 "new". If the user is online, their WebSocket server pushes the notification instantly.XREVRANGE. Nothing was missed.The Feed Data Model and Commands
Keep one stream per user for their notifications, a counter for unread, and a Pub/Sub channel for the live ping. Reading the feed is a reverse range; marking it read resets the counter.
# 1) a new notification for user 88 (store it, durably)
XADD notif:user:88 MAXLEN ~ 200 * type "like" actor 57 post 9001
# 2) bump the unread badge
INCR unread:user:88
# 3) ping any live client for this user
PUBLISH user:88 "new"
# --- when the user opens their notifications ---
# newest 20 notifications, newest first
XREVRANGE notif:user:88 + - COUNT 20
# mark all as read: reset the unread badge to 0
SET unread:user:88 0
# show the badge number any time
GET unread:user:88
| Need | Redis key | Command |
|---|---|---|
| Store a notification | notif:user:88 (stream) | XADD ... MAXLEN ~ 200 |
| Unread badge | unread:user:88 (string) | INCR / SET 0 |
| Live push | user:88 (channel) | PUBLISH / SUBSCRIBE |
| Read the feed | notif:user:88 | XREVRANGE + - COUNT n |
A user rarely scrolls past the last couple of hundred notifications. Add MAXLEN ~
200 to XADD so each feed stays small and memory stays flat. Older
notifications fall off the end automatically.
Putting It Together
Here is the whole flow when Asha comments on Ben's post. The comment is saved in your database as usual; the notification uses Redis for speed and live delivery.
# after saving the comment in your main database ...
# build Ben's notification (user 88), keep his feed capped
XADD notif:user:88 MAXLEN ~ 200 * type "comment" actor 57 post 9001 text "Nice shot!"
INCR unread:user:88
PUBLISH user:88 "new"
# Ben's phone is online: its WebSocket server is subscribed and pushes at once
# Ben's laptop is offline: next time it opens, it calls XREVRANGE and sees the comment
The stream makes the feed reliable: it survives a restart and offline users catch up. Pub/Sub makes it feel instant for online users. Consumer groups let you process heavy events (emails, push, digests) with many workers and never lose one.
Golden Rules
XAUTOCLAIM to take over messages a
dead worker left pending, so nothing waits forever.
MAXLEN ~ 200 so notification streams
stay small and memory stays flat.
INCR on a new notification,
SET 0 when the user opens the feed. The badge is then a single fast read.