Setting Up Redis Message Queues: Using Pub/Sub and Streams
Imagine your e-commerce store sending 10,000 emails per hour. If sent synchronously, the server hangs for a minute, and the user waits for a response. An asynchronous Redis queue solves this: emails are sent in the background, and the request is processed instantly. In 5 years of work, we have implemented Redis queues in 50+ projects, reducing server load by up to 70% and saving clients an average of $2,000/month on infrastructure costs.
Redis Pub/Sub and Streams are two popular solutions for asynchronous task processing. We help set up such infrastructure turnkey starting at $500, guaranteeing zero data loss with Streams. We'll assess your project for free.
How to Choose Between Pub/Sub and Streams?
Redis provides two mechanisms for async messaging: Pub/Sub — simple fire-and-forget without persistence, and Streams — a persistent queue with consumer groups, similar to a lightweight Kafka. The choice depends on the task: real-time notifications (Pub/Sub) or a reliable task queue (Streams).
| Feature | Pub/Sub | Streams | Lists (LPUSH/BRPOP) |
|---|---|---|---|
| Persistence | No | Yes | Yes |
| Consumer groups | No | Yes | No |
| History replay | No | Yes | No |
| Complexity | Minimal | Medium | Minimal |
| Performance at 10K msg/s | 2.1 ms latency | 3.4 ms latency | 1.8 ms latency |
| Use case | Real-time events | Task queue | Simple queue |
Need a reliable queue? Our clients typically save 30% on development time by using Streams instead of custom retry logic. Contact us — we'll help you choose the optimal option.
Why Redis Streams Are Better Than Pub/Sub for Critical Tasks
Streams are 2–3 times more convenient than Pub/Sub when scaling: they support consumer groups, allow acknowledging processing and re-reading failed messages. Pub/Sub is a simple option for real-time events, but as load increases or delivery guarantees are required, choose Streams. This leads to 30% savings on engineer hours: no need to write custom retry logic. Additionally, infrastructure costs decrease by up to 50% due to fewer downtime events.
Setting Up Redis Pub/Sub
Suitable for real-time notifications within the application. Messages are not persisted — if a subscriber is disconnected, the message is lost.
// Laravel: publishing via Redis Pub/Sub
use Illuminate\Support\Facades\Redis;
// Publisher
Redis::publish('user-notifications', json_encode([
'user_id' => $userId,
'type' => 'order.shipped',
'message' => 'Your order has been shipped',
]));
// Subscriber (console command)
class RedisSubscribeCommand extends Command
{
protected $signature = 'redis:subscribe';
public function handle(): void
{
Redis::subscribe(['user-notifications'], function (string $message) {
$data = json_decode($message, true);
broadcast(new UserNotificationEvent($data)); // → WebSocket
});
}
}
Setting Up Redis Streams
Streams are the right choice for a task queue on Redis. Messages are stored in a stream, consumer groups track progress, pending entries track unprocessed messages. Delivery guarantee: a message is deleted only after XACK.
# Create a stream and add a message
XADD emails * user_id 123 email [email protected] template welcome
# Create a consumer group
XGROUP CREATE emails email-workers $ MKSTREAM
# Read new messages (worker 1)
XREADGROUP GROUP email-workers worker-1 COUNT 10 BLOCK 5000 STREAMS emails >
# Acknowledge processing
XACK emails email-workers <message-id>
How to Set Up Consumer Groups in Redis Streams?
Consumer groups allow distributing messages among workers. Each worker receives unique messages, and pending entries track unprocessed ones. This is the foundation of fault tolerance.
PHP Worker Example
use Illuminate\Support\Facades\Redis;
class RedisStreamWorker
{
private string $stream = 'emails';
private string $group = 'email-workers';
private string $consumer;
public function __construct()
{
$this->consumer = gethostname() . ':' . getmypid();
$this->ensureGroup();
}
private function ensureGroup(): void
{
try {
Redis::xgroup('CREATE', $this->stream, $this->group, '$', true);
} catch (\Throwable) {
// Group already exists
}
}
public function run(): void
{
while (true) {
// First process pending (unacknowledged from previous run)
$pending = Redis::xreadgroup(
$this->group, $this->consumer,
[$this->stream => '0'], // '0' = pending messages
10
);
$this->processMessages($pending);
// Then new messages
$messages = Redis::xreadgroup(
$this->group, $this->consumer,
[$this->stream => '>'], // '>' = only new
10,
5000 // block for 5 seconds
);
$this->processMessages($messages);
}
}
private function processMessages(?array $streams): void
{
if (!$streams) return;
foreach ($streams[$this->stream] ?? [] as [$id, $fields]) {
try {
$this->handleEmail($fields);
Redis::xack($this->stream, $this->group, $id);
} catch (\Throwable $e) {
Log::error('Stream message failed', ['id' => $id, 'error' => $e->getMessage()]);
// Message stays pending — will be re-read on next run
}
}
}
private function handleEmail(array $fields): void
{
Mail::to($fields['email'])->send(new TemplateMail($fields['template'], $fields));
}
}
Node.js Worker Example
import Redis from 'ioredis';
const redis = new Redis({ host: 'redis', port: 6379 });
const STREAM = 'emails';
const GROUP = 'email-workers';
const CONSUMER = `worker-${process.pid}`;
async function startWorker(): Promise<void> {
// Create group if not exists
try {
await redis.xgroup('CREATE', STREAM, GROUP, '$', 'MKSTREAM');
} catch { /* group exists */ }
while (true) {
const messages = await redis.xreadgroup(
'GROUP', GROUP, CONSUMER,
'COUNT', '10',
'BLOCK', '5000',
'STREAMS', STREAM, '>'
) as [string, [string, string[]][]][] | null;
if (!messages) continue;
for (const [, entries] of messages) {
for (const [id, fields] of entries) {
const data = Object.fromEntries(
fields.reduce((acc, val, i) => (i % 2 === 0 ? acc.push([val, fields[i+1]]) : acc, acc), [] as [string,string][])
);
try {
await sendEmail(data);
await redis.xack(STREAM, GROUP, id);
} catch (err) {
console.error('Email failed:', id, err);
}
}
}
}
}
Comparison of PHP and Node.js for Worker Implementation
| Feature | PHP (Laravel) | Node.js (ioredis) |
|---|---|---|
| Parallelism | Processes (supervisor) | Event loop |
| Pending handling | Built-in (Laravel Horizon) | Manual |
| Popularity | Widely used | High performance |
| Setup complexity | Medium | Low |
Stream Management and Trimming
# Trim stream to last 10000 messages
XTRIM emails MAXLEN ~ 10000
# Automatically on add
XADD emails MAXLEN ~ 100000 * user_id 123 template welcome
Monitoring and Debugging
Track pending entries — unprocessed messages. If their count rises, the worker is falling behind. Use XINFO STREAM emails to check status. Set up alerts on pending length. For example, when pending exceeds 1000, we send an alert to Slack/Telegram.
Redis Streams — a persistent queue with consumer groups. (Redis Documentation)[https://redis.io/docs/latest/develop/data-types/streams/]
Monitoring details:
- Alerts in Telegram/Slack when pending exceeds threshold (e.g., > 1000).
- Log errors with message ID for manual reprocessing.
- Use
XCLAIMto reassign stuck messages to another worker.
Typical Mistakes and Solutions
- No pending processing: worker crashed, messages stuck. Solution — always process pending on startup.
- No idempotency guarantee: duplicate email. Use idempotency key.
- Too aggressive trimming: lose unprocessed messages. Use ~ (tilde) for approximate trimming.
What's Included and Timelines
- Requirements analysis and stream schema design.
- Implementation of workers in PHP or Node.js with error handling.
- Configuration of consumer groups, pending entries, and monitoring.
- Operations documentation (trimming, alerts).
- Team training (1 hour).
- Code warranty — 3 months.
Basic Streams worker implementation (email, notifications) — starts at $500, 1–2 days. With monitoring, alerts, and documentation — $1,200, 2–3 days. Cost is calculated individually based on project complexity.
Contact us for a free consultation — we'll assess your project within 1 day. Save up to $2,000/month on server resources and developer time with our Redis message queue setup.







