Fix: Redis Streams Not Working — Consumer Groups, XACK, Pending Entries, MAXLEN, and Claiming
Quick Answer
How to fix Redis Streams errors — XADD/XREAD basics, consumer group XGROUP CREATE, XACK for ack, XPENDING for stuck messages, MAXLEN ~ for trimming, XAUTOCLAIM for redelivery, and Cluster hash slot constraints.
The Error
You add to a stream but readers see nothing:
XADD events * type signup userId 42
"1716240000000-0"
# In another shell:
XREAD COUNT 10 STREAMS events 0
# Returns nil after the first read.Or messages don’t redistribute among consumers in a group:
XREADGROUP GROUP grp1 worker-a COUNT 10 STREAMS events >
# worker-a gets all messages; worker-b sees nothing.Or a stream grows forever despite trimming:
XADD events MAXLEN 10000 * field value
XLEN events
# Returns 50000 — way more than 10000.Or XACK doesn’t clear the pending entries list:
XACK events grp1 1716240000000-0
XPENDING events grp1
# Still shows 100 pending entries.Why This Happens
Redis Streams is an append-only log with consumer-group semantics, modeled after Kafka but built into Redis. Most issues map to:
- XREAD vs XREADGROUP.
XREADreturns messages by ID (any subscriber sees everything).XREADGROUPdistributes among group consumers — only one consumer per message. >vs explicit ID. With XREADGROUP,>means “messages never delivered to this group.” Any other ID means “from the pending list.”- PEL (Pending Entries List). Once XREADGROUP delivers a message, it stays in the consumer’s PEL until XACK or claimed by another consumer. Acks are mandatory; otherwise messages accumulate.
- MAXLEN with
~is approximate.XADD events MAXLEN ~ 10000 *is fast but allows some over-shoot. Exact trimming (MAXLEN 10000) is slower. - Cluster + Streams = same key in one slot. Streams aren’t easily sharded across cluster nodes.
Fix 1: Use XADD / XREAD for Simple Publish/Subscribe
# Add an entry (auto-generated ID with *):
XADD events * type signup userId 42
# Read everything from the start:
XREAD COUNT 100 STREAMS events 0
# Read new entries (blocking):
XREAD BLOCK 5000 COUNT 100 STREAMS events $
# $ means "from the latest ID at the time of the call."
# Read by specific ID range:
XRANGE events - +
XRANGE events 1716240000000 1716250000000XREAD is fan-out — every consumer that calls it sees the same messages. Suitable for fire-and-forget broadcast.
For Node:
import { createClient } from "redis";
const client = createClient();
await client.connect();
await client.xAdd("events", "*", { type: "signup", userId: "42" });
// Block until new messages arrive:
const messages = await client.xRead(
{ key: "events", id: "$" },
{ BLOCK: 5000, COUNT: 100 },
);For Python:
import redis
r = redis.Redis()
r.xadd("events", {"type": "signup", "userId": 42})
messages = r.xread({"events": "$"}, block=5000, count=100)Pro Tip: Use XADD ... NOMKSTREAM to fail if the stream doesn’t exist (avoids accidentally creating streams from typos).
Fix 2: Consumer Groups for Distributed Processing
For load-balanced consumption (one message per consumer in the group):
# Create the group (only once):
XGROUP CREATE events grp1 0 MKSTREAM
# 0 = start from the beginning; $ = only new messages
# MKSTREAM = create the stream if it doesn't exist
# Consumer A:
XREADGROUP GROUP grp1 worker-a COUNT 10 BLOCK 5000 STREAMS events >
# Consumer B (in parallel):
XREADGROUP GROUP grp1 worker-b COUNT 10 BLOCK 5000 STREAMS events >Each worker gets distinct messages. Redis tracks which group has seen which IDs.
In Node:
// First-time setup (idempotent):
try {
await client.xGroupCreate("events", "grp1", "0", { MKSTREAM: true });
} catch (e) {
if (!e.message.includes("BUSYGROUP")) throw e;
}
// Consumer loop:
async function consume(workerId: string) {
while (true) {
const result = await client.xReadGroup(
"grp1",
workerId,
{ key: "events", id: ">" },
{ BLOCK: 5000, COUNT: 10 },
);
if (!result) continue;
for (const { messages } of result) {
for (const { id, message } of messages) {
try {
await process(message);
await client.xAck("events", "grp1", id);
} catch (err) {
console.error("failed to process:", id, err);
// Don't ack — will be redelivered or claimed later.
}
}
}
}
}Common Mistake: Calling XGROUP CREATE on every startup without catching BUSYGROUP. The group already exists; the error is fatal. Catch and ignore.
Fix 3: ACK and PEL Management
After XREADGROUP, messages sit in your consumer’s Pending Entries List (PEL) until acked:
# Inspect the PEL:
XPENDING events grp1
# Shows: total count, min/max ID, consumer counts
XPENDING events grp1 - + 10
# Shows up to 10 pending entries with detailsWithout XACK, the PEL grows. Memory leak.
After successful processing:
XACK events grp1 1716240000000-0
# Returns 1 if acked, 0 if the message wasn't in the PEL (already acked or never delivered to this group).For batch ack:
XACK events grp1 1716240000000-0 1716240000001-0 1716240000002-0
# Acks multiple at once.Pro Tip: Always ack inside a try/finally or use exception-safe wrappers. A crash between processing and ack leaves the message in PEL for redelivery — that’s the at-least-once delivery guarantee in action.
Fix 4: MAXLEN and Trimming
Append with trim:
# Approximate trim (fast, may overshoot slightly):
XADD events MAXLEN ~ 10000 * field value
# Exact trim (slower, hits a precise limit):
XADD events MAXLEN 10000 * field value
# Cap by minimum ID (delete everything older than X):
XADD events MINID ~ 1716200000000 * field value~ allows the trim to happen in whole macro-nodes (faster). = (or no symbol) forces exact trimming.
For periodic trim outside XADD:
XTRIM events MAXLEN ~ 10000
XTRIM events MINID 1716200000000Run via a cron job for streams that grow during quiet periods.
For unbounded growth diagnostics:
XLEN events
XINFO STREAM eventsXINFO STREAM shows total length, last-delivered ID per group, and approximate radix tree size.
Common Mistake: Setting MAXLEN per XADD but only ~ approximate, then complaining about overshoot. Switch to = for strict caps, or run a separate XTRIM periodically.
Fix 5: Claim Stalled Messages
If a consumer crashes mid-process, its PEL entries are stuck. XAUTOCLAIM (or older XPENDING + XCLAIM) reassigns them:
# Auto-claim messages idle > 60000ms (1 minute):
XAUTOCLAIM events grp1 worker-b 60000 0 COUNT 10
# 0 = start scanning from ID 0
# Returns up to 10 messages claimed by worker-bIn a loop:
async function claimStalled(workerId: string) {
const result = await client.xAutoClaim("events", "grp1", workerId, 60000, "0", {
COUNT: 10,
});
for (const { id, message } of result.messages) {
try {
await process(message);
await client.xAck("events", "grp1", id);
} catch (err) {
// Will be auto-claimed again next time.
}
}
}
// Run claimStalled periodically alongside the main consumer loop.XAUTOCLAIM does atomically what XPENDING + XCLAIM would do manually — and is much simpler. Available in Redis 6.2+.
For older Redis:
XPENDING events grp1 - + 10
# Returns list of pending entries with idle times.
XCLAIM events grp1 worker-b 60000 1716240000000-0
# Move that specific entry to worker-b.Pro Tip: Run XAUTOCLAIM on a dedicated worker or in the main loop with low frequency (every 10-30 seconds). Avoid running it from many workers concurrently — they’ll fight over claims.
Fix 6: Dead Letter Queue Pattern
For messages that fail repeatedly, route to a DLQ:
async function processWithDLQ(id: string, message: Record<string, string>, retryCount: number) {
try {
await actuallyProcess(message);
await client.xAck("events", "grp1", id);
} catch (err) {
if (retryCount >= 3) {
// Move to DLQ:
await client.xAdd("events:dlq", "*", {
...message,
original_id: id,
error: String(err),
retries: String(retryCount),
});
await client.xAck("events", "grp1", id);
}
// Else: don't ack, let it be redelivered.
}
}Track retry counts via XPENDING delivery count:
XPENDING events grp1 - + 10 worker-a
# Each row includes: id, consumer, idle_time, delivery_countAfter 3+ deliveries, your code decides to give up.
Fix 7: Cluster Constraints
Redis Cluster shards by key hash slot. Streams are a single key — events lives in one slot, on one master. You can’t split a stream across nodes.
For scaling beyond one node’s capacity:
- Multiple streams. Hash partition application-side:
events:{user_id % 16}. - Curly braces
{hash_tag}to keep related keys on the same slot if needed.
const userId = 42;
const streamKey = `events:{${userId % 16}}`; // {bracketed} part is the hash tag
await client.xAdd(streamKey, "*", { userId: String(userId), ... });This sharding pattern produces 16 streams, each on potentially different cluster nodes. Consumers subscribe to all 16.
Common Mistake: Trying to use a single stream beyond ~50K msgs/sec on one node. Even fast Redis hits limits — partition by key.
Fix 8: Memory and Operational Concerns
Streams accumulate data. Without trimming, a busy stream can fill all Redis memory:
# Memory used by a stream:
MEMORY USAGE eventsFor tight memory budgets:
- Approximate MAXLEN on every XADD. Cheap; keeps growth bounded.
- Periodic XTRIM with MINID. Time-based — keep last 24 hours.
- Persistence (AOF / RDB). Streams are persisted like other data; massive streams slow down BGSAVE.
For production:
# In redis.conf:
maxmemory 8gb
maxmemory-policy noeviction # Or volatile-lru, allkeys-lru depending on use case.noeviction makes Redis return errors on memory exhaustion rather than silently dropping data — safer for streams where eviction would corrupt the log.
Pro Tip: Use a separate Redis instance for high-volume streams. Mixing cache (eviction-tolerant) and streams (eviction-intolerant) in one Redis instance forces uncomfortable trade-offs.
Still Not Working?
A few less-obvious failures:
BUSYGROUP Consumer Group name already exists. Catch this — the group exists from a previous run. Not an error.NOGROUP No such key 'events' or consumer group 'grp1' in XREADGROUP. Stream or group missing. RunXGROUP CREATE ... MKSTREAM.XREADGROUPreturns nothing. Either no new messages (with>) or your group has consumed everything. CheckXPENDINGfor stuck entries.- Memory usage grows even with MAXLEN. Approximate trim allows some overshoot. Use
=(exact) periodically or a smaller threshold. - Producer faster than consumer. Backlog grows. Either add consumers, faster processing, or accept lag.
- Different libraries return different formats. node-redis v4 differs from ioredis. Always check your client’s docs.
- Crash recovery loses recent messages. AOF with
appendfsync everysecmay lose up to 1 second. For critical streams, useappendfsync always(slower) or external persistence (Kafka). XADDwith explicit ID fails.XADD events 123-0 *— the ID must be greater than the stream’s last ID. Use*to auto-generate or pick a higher ID.
For related Redis and event streaming issues, see Redis connection refused, Redis pub sub not working, Valkey not working, and Kafka not working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Valkey Not Working — Redis Client Compatibility, ACL, Cluster Mode, and Migration
How to fix Valkey errors — client connection refused, RESP protocol compatibility, ACL user setup, cluster slot reshard, persistence config (RDB/AOF), TLS, Sentinel mode, and migrating from Redis.
Fix: NATS Not Working — Connection Auth, JetStream Streams, Consumer Ack, and Subject Wildcards
How to fix NATS errors — no responders to request, JetStream stream not found, consumer redelivery loop, durable vs ephemeral consumers, subject wildcard mismatch, TLS auth setup, and KV bucket basics.
Fix: Cloudflare Queues Not Working — Producer Binding, Consumer Worker, Batching, and Dead Letter
How to fix Cloudflare Queues errors — producer queue.send not delivering, consumer not invoking, ack/retry/DLQ patterns, batch size limits, max_retries, content type pitfalls, and local dev with wrangler.
Fix: Node.js Stream pipeline() Not Working — Backpressure, Error Propagation, AbortSignal, and Web Streams Interop
How to fix Node.js stream/promises pipeline errors — uncaught stream errors, backpressure ignored, AbortSignal not propagating, async iterators in pipeline, Transform stream object mode, and converting between Node and Web Streams.