When designing distributed microservices and asynchronous event pipelines, engineering teams often reach immediately for heavyweight message brokers like Apache Kafka or RabbitMQ. While these tools are exceptional for massive multi-terabyte data hubs, they introduce significant operational overhead: Zookeeper/KRaft clusters, complex partition management, and high baseline memory consumption.
For high-throughput web applications processing thousands of events per second, Redis Streams offers a remarkably fast, lightweight, and mathematically robust event-streaming engine built directly into your existing in-memory database.
Unlike standard Redis Pub/Sub (which provides ephemeral "fire-and-forget" broadcasting with zero historical persistence), Redis Streams provides an append-only log data structure with persistent message offsets, consumer groups, pending entries lists (PEL), and at-least-once delivery guarantees.
In this guide, we explore the internal mechanics of Redis Streams, build a custom streaming producer and consumer group worker in Laravel, and implement fault-tolerant message recovery using XAUTOCLAIM.
Pub/Sub vs. Lists vs. Redis Streams
| Redis Data Structure | Message Persistence | Delivery Model | Consumer Group & ACK Support |
|---|---|---|---|
Pub/Sub (PUBLISH / SUBSCRIBE) |
None. If no subscriber is connected, the message is lost forever. | Broadcast to all active listeners. | ✗ No ACK mechanism. |
Lists (LPUSH / BRPOP) |
Persistent in RAM until popped. | Point-to-point (Single consumer consumes each item). | ✗ No fan-out to multiple independent service groups. |
Streams (XADD / XREADGROUP) |
Persistent append-only log with configurable trimming (MAXLEN). |
Fan-out to multiple Consumer Groups; load balanced within each group. | ✓ Full XACK, Pending Entries List, and Dead-Letter recovery. |
1. The Stream Anatomy: Consumer Groups & The Pending Entries List (PEL)
When multiple microservices consume the same event stream (e.g., when an order is placed, both the Payment Service and the Inventory Service must process the event independently):
- Stream Append (
XADD): Producers append events with a monotonically increasing ID (<millisecondsTime>-<sequenceNumber>). - Consumer Groups (
XGROUP CREATE): Each service registers a named consumer group with its own independent stream cursor. - Reading Pending Events (
XREADGROUP): Workers inside a consumer group request new unassigned messages (using ID>). Redis assigns messages to the worker and records them in the Pending Entries List (PEL). - Explicit Acknowledgement (
XACK): Once a worker successfully finishes processing, it issuesXACK. Redis removes the message from the PEL. - Crash Recovery (
XAUTOCLAIM): If a worker crashes mid-task, the message remains unacknowledged in the PEL. Another worker claims and processes abandoned messages after a timeout.
2. Producing Stream Events in Laravel
Let's create an event streaming producer service in Laravel:
<?php
namespace App\Services\Streams;
use Illuminate\Support\Facades\Redis;
class OrderStreamProducer
{
protected const STREAM_KEY = 'stream:orders';
/**
* Publish an order event to Redis Streams with approximate trimming (~100k events).
*/
public static function publish(string $eventType, array $data): string
{
$payload = [
'event' => $eventType,
'data' => json_encode($data),
'timestamp' => (string) now()->getTimestampMs(),
];
// XADD with MAXLEN ~ 100000 keeps memory bounded while retaining recent history
return Redis::xadd(
self::STREAM_KEY,
'MAXLEN',
'~',
100000,
'*',
$payload
);
}
}
3. Building a Resilient Consumer Group Worker
Create an Artisan console command that runs continuously under systemd to consume stream events with automatic error recovery and dead-letter handling:
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
use Throwable;
class ConsumeOrderStreamCommand extends Command
{
protected $signature = 'stream:consume-orders {--group=inventory-service} {--consumer=}';
protected $description = 'Consume order events from Redis Streams with ACK lifecycle and auto-claiming';
protected string $stream = 'stream:orders';
public function handle(): int
{
$group = $this->option('group');
$consumer = $this->option('consumer') ?: 'worker-' . getmypid();
// 1. Ensure consumer group exists (Start from beginning of stream '0')
try {
Redis::xgroup('CREATE', $this->stream, $group, '0', 'MKSTREAM');
$this->info("Created consumer group: {$group}");
} catch (Throwable $e) {
// Group already exists, continue gracefully
}
$this->info("Worker {$consumer} listening on {$this->stream} (Group: {$group})...");
while (true) {
try {
// 2. First, check for abandoned messages stuck in PEL for over 60 seconds (Crash Recovery)
$claimed = Redis::xautoclaim($this->stream, $group, $consumer, 60000, '0-0', 'COUNT', 10);
if (!empty($claimed[1])) {
foreach ($claimed[1] as $id => $fields) {
$this->processMessage($id, $fields, $group);
}
}
// 3. Read new unassigned messages with a 2-second blocking timeout
$entries = Redis::xreadgroup($group, $consumer, [$this->stream => '>'], 10, 2000);
if (!empty($entries[$this->stream])) {
foreach ($entries[$this->stream] as $id => $fields) {
$this->processMessage($id, $fields, $group);
}
}
} catch (Throwable $e) {
Log::error("Stream consumer error: " . $e->getMessage());
sleep(1);
}
}
return self::SUCCESS;
}
protected function processMessage(string $id, array $fields, string $group): void
{
$event = $fields['event'] ?? 'unknown';
$data = json_decode($fields['data'] ?? '{}', true);
$this->line("→ Processing event [{$id}]: {$event}");
try {
// Business Logic: Reserve inventory or dispatch notifications
// ...
// 4. Acknowledge message successfully
Redis::xack($this->stream, $group, [$id]);
} catch (Throwable $e) {
Log::error("Failed to process stream message {$id}: " . $e->getMessage());
// Message remains in PEL for retry or DLQ routing
}
}
}
4. Stream Trimming & Memory Optimization
Because Redis is an in-memory database, unbounded streams will eventually exhaust RAM. Always use approximate trimming (MAXLEN ~) on production streams:
# Approximate trimming is O(1) computationally, avoiding re-indexing overhead:
XADD stream:orders MAXLEN ~ 50000 * event "order.placed" data "{\"id\":1024}"
Production Redis Streams Checklist
- ✓ Always ACK after database commits: Issue
XACKonly after the consumer has successfully committed its database transaction. - ✓ Run
XAUTOCLAIMin worker loops: Guarantees that if a worker process crashes due to OOM or network drops, other workers pick up abandoned messages. - ✓ Set approximate stream capping: Use
MAXLEN ~ NonXADDto maintain predictable memory usage. - ✓ Monitor Consumer Group Lag: Run
XINFO GROUPS stream:ordersto monitor unconsumed message backlogs.
Conclusion
Redis Streams strikes the perfect balance between the lightweight simplicity of in-memory caching and the robust guarantees of enterprise event streaming. By leveraging consumer groups, explicit ACK lifecycles, and auto-claiming in Laravel, you can build resilient, distributed event-driven systems with sub-millisecond end-to-end latency.