Redis Pub/Sub Event Broker in Node.js: Step-by-Step Guide

by Fahim

Chaining HTTP requests across microservices is a recipe for slow, fragile systems. If one service goes down, the whole chain collapses. To decouple your services, you need a lightweight, asynchronous event broker. We can build one in a few minutes using Redis Pub/Sub and Node.js.

I used to jump straight to heavy tools like RabbitMQ or Kafka the second I needed event-driven architecture. Big mistake. I ended up with a massive cloud bill and spent three days fighting routing keys. If you’re already running Redis for caching, its built-in publish/subscribe engine can handle thousands of events per second with zero configuration headache.

Redis Pub/Sub Event Broker terminal logs showing active subscription channels
Redis Pub/Sub Event Broker terminal logs showing active subscription channels

Why Redis Pub/Sub?

Redis Pub/Sub is fire-and-forget. A publisher sends a message to a channel, and Redis instantly pushes it to active subscribers. It’s incredibly fast because Redis doesn’t write messages to disk or track who received what.

If you need persistent queues where messages wait around for offline workers to wake up, go read my guide on managing rate limits using BullMQ. But if you need instant, real-time broadcasts—like updating a dashboard, triggering notification workers, or syncing state—Redis Pub/Sub is the fastest tool in your shed.

Before we write code, check out the official Redis Pub/Sub documentation. There’s one massive gotcha: a single Redis connection cannot run regular database queries while in subscriber mode. We have to spin up at least two separate Redis connections: one to publish, and one to subscribe.

Setting Up Your Local Environment

Let’s spin up a clean project. I’m using Node.js v20.11.0 and Redis v7.2 via Docker. Create a new directory and initialize it:

What I ran in my terminal:

mkdir redis-pubsub-broker
cd redis-pubsub-broker
npm init -y
npm install ioredis dotenv

We’ll use the ioredis client library because it handles reconnects automatically and works perfectly with ES modules. Let’s set up our environment variables. Create a .env file in your root folder:

REDIS_URL=redis://127.0.0.1:6379
PORT=3000

If you don’t have Redis running locally yet, spin up a quick container with this:

docker run -d --name local-redis -p 6379:6379 redis:7.2-alpine

Building the Core Event Broker Class

We need a clean class to manage our publisher and subscriber connections. It should handle initialization, channel subscriptions, and publishing typed events. Let’s create EventBroker.js.

Here’s the implementation:

import Redis from 'ioredis';
import dotenv from 'dotenv'; dotenv.config(); class EventBroker { constructor() { const redisUrl = process.env.REDIS_URL || 'redis://127.0.0.1:6379'; // We need two separate clients this.publisher = new Redis(redisUrl, { maxRetriesPerRequest: null }); this.subscriber = new Redis(redisUrl, { maxRetriesPerRequest: null }); this.setupErrorHandling(); } setupErrorHandling() { this.publisher.on('error', (err) => { console.error('Redis Publisher Error:', err.message); }); this.subscriber.on('error', (err) => { console.error('Redis Subscriber Error:', err.message); }); this.subscriber.on('connect', () => { console.log('Redis Subscriber connected successfully.'); }); } async publish(channel, eventData) { try { const payload = JSON.stringify({ event: channel, timestamp: Date.now(), data: eventData }); await this.publisher.publish(channel, payload); } catch (error) { console.error(`Failed to publish to channel ${channel}:`, error); throw error; } } async subscribe(channel, callback) { try { await this.subscriber.subscribe(channel); this.subscriber.on('message', (chan, message) => { if (chan === channel) { try { const parsed = JSON.parse(message); callback(parsed); } catch (parseError) { console.error('Failed to parse incoming message:', message); } } }); console.log(`Subscribed to channel: ${channel}`); } catch (error) { console.error(`Failed to subscribe to channel ${channel}:`, error); throw error; } } async disconnect() { await Promise.all([ this.publisher.quit(), this.subscriber.quit() ]); console.log('Redis connections closed.'); }
} export const broker = new EventBroker();

This class wraps our raw Redis clients. By formatting our payloads with a consistent structure (timestamp, event name, and data), we make sure downstream services can parse and validate messages without breaking.

Creating the Publisher and Subscriber Services

To test this, we’ll build two separate scripts simulating different microservices. In production, these would run in separate containers on your cluster.

First, the subscriber. Create notification-service.js. This service listens for user registration events and triggers confirmation emails.

import { broker } from './EventBroker.js'; console.log('Starting Notification Service...'); broker.subscribe('user.registered', (payload) => { const { data, timestamp } = payload; const delay = Date.now() - timestamp; console.log(`[Notification Service] Received user.registered event!`); console.log(`User ID: ${data.userId}`); console.log(`Email: ${data.email}`); console.log(`Event delivery delay: ${delay}ms`); console.log('--------------------------------------------');
});

Next, let’s write our publisher script to simulate a web app handling registration requests. Create web-app.js:

import { broker } from './EventBroker.js'; async function simulateUserRegistration() { const mockUser = { userId: `user_${Math.floor(Math.random() * 1000000)}`, email: 'dev@isitdev.com', signupSource: 'organic' }; console.log(`[Web App] Registering user: ${mockUser.userId}`); // Publish the event await broker.publish('user.registered', mockUser);
} // Run every 3 seconds to simulate real organic traffic
setInterval(simulateUserRegistration, 3000);

Open two terminal windows. In the first, boot up the subscriber:

node notification-service.js

In the second, trigger the publisher:

node web-app.js

You’ll see the web app fire off registration events, and the notification service will instantly catch them with a 1ms to 3ms delay. That’s the beauty of keeping your broker in-memory.

Handling Connection Drops and Reconnects

This is where production gets messy. If your Redis instance restarts or the network hiccups, your subscriber connections will drop. By default, ioredis reconnects, but your subscriber client might lose its active subscriptions depending on how the connection died.

To fix this, we need to track active channels inside our EventBroker class and re-subscribe whenever the connection comes back up. Let’s update EventBroker.js to handle this automatically.

Here’s the updated setup:

class EventBroker { constructor() { const redisUrl = process.env.REDIS_URL || 'redis://127.0.0.1:6379'; this.publisher = new Redis(redisUrl, { maxRetriesPerRequest: null }); this.subscriber = new Redis(redisUrl, { maxRetriesPerRequest: null }); // Keep track of active subscriptions this.activeSubscriptions = new Map(); this.setupErrorHandling(); this.setupReconnectionHandlers(); } setupReconnectionHandlers() { this.subscriber.on('ready', async () => { if (this.activeSubscriptions.size > 0) { console.log('Re-establishing active Redis subscriptions...'); for (const channel of this.activeSubscriptions.keys()) { await this.subscriber.subscribe(channel); console.log(`Re-subscribed to: ${channel}`); } } }); } async subscribe(channel, callback) { try { // Store callback in our active subscriptions map if (!this.activeSubscriptions.has(channel)) { this.activeSubscriptions.set(channel, []); } this.activeSubscriptions.get(channel).push(callback); await this.subscriber.subscribe(channel); // Setup single message handler to route to registered callbacks if (this.subscriber.listeners('message').length === 0) { this.subscriber.on('message', (chan, message) => { const callbacks = this.activeSubscriptions.get(chan); if (callbacks) { try { const parsed = JSON.parse(message); callbacks.forEach(cb => cb(parsed)); } catch (err) { console.error('Failed to process message callbacks:', err); } } }); } console.log(`Subscribed and registered callback for: ${channel}`); } catch (error) { console.error(`Failed to subscribe to channel ${channel}:`, error); throw error; } }
}

Now, if your Redis container restarts, your Node.js app will automatically re-register its listeners. No manual process reboots required. This saves you from silent failures where the app looks healthy but isn’t receiving a single message.

Pattern Matching for Complex Routing (PSUBSCRIBE)

As your system grows, subscribing to channels one by one gets tedious. Redis supports pattern-matching subscriptions via the PSUBSCRIBE command. It’s incredibly useful for dynamic routing.

For instance, you might want an analytics worker to listen to all user-related events (like user.registered, user.updated, user.deleted). Instead of subscribing to three separate channels, you just subscribe to user.*.

Let’s add a pattern subscription method to our broker class:

async pSubscribe(pattern, callback) { try { await this.subscriber.psubscribe(pattern); this.subscriber.on('pmessage', (matchedPattern, channel, message) => { if (matchedPattern === pattern) { try { const parsed = JSON.parse(message); callback(channel, parsed); } catch (err) { console.error('Failed to parse pattern message:', err); } } }); console.log(`Pattern subscription active for: ${pattern}`); } catch (error) { console.error(`Pattern subscription failed for ${pattern}:`, error); throw error; }
}

Now we can run an analytics listener that intercepts every event matching our namespace:

broker.pSubscribe('user.*', (channel, payload) => { console.log(`[Analytics] Intercepted event on channel [${channel}]:`, payload.data);
});

This keeps your code clean and lets you add new event types without constantly updating your downstream consumer configurations.

Redis Pub/Sub vs. Message Queues: When to Use What

It’s easy to over-engineer your messaging pipeline. I use a simple rule of thumb when deciding between Redis Pub/Sub and dedicated message queues like RabbitMQ or BullMQ.

Feature Redis Pub/Sub BullMQ / RabbitMQ Delivery Model Fire-and-forget (Broadcast) Point-to-point (Queue) Persistence In-memory only (None) Persistent (Redis DB or Disk) Offline Consumers Messages are lost Messages wait in queue Speed Extremely fast (< 2ms) Fast (< 10ms) but limited by disk writes

If missing a message is acceptable (like live dashboard metrics or user typing indicators), use Redis Pub/Sub. If every single message must be processed eventually (like processing invoices or sending transactional emails), use a queue. For critical database updates, I always pair Pub/Sub with a reliable lock to prevent duplicate writes; see my guide on implementing a Redis distributed lock to prevent race conditions.

Common Gotchas I Solved the Hard Way

When running this in production, you’ll eventually hit these limitations. Here’s how I solved them:

  • The “Connection is Closed” Error: This happens when you accidentally try to call broker.publisher.set() or other standard Redis commands on your subscriber client. Always make sure your application code uses the publisher instance for standard key-value operations. You can learn more about how to structure your caching clients in our Redis Node.js caching tutorial.
  • Subscriber Buffer Overflows: If your subscriber is slow to process messages, Redis will buffer them in memory. If the buffer exceeds your configured limits, Redis will forcefully terminate the client connection. Keep your subscriber callback functions thin. If a callback needs to perform a heavy database query, offload it to a background worker immediately.
  • Scaling Horizontally: If you run multiple instances of your subscriber service behind a load balancer, each instance will receive a copy of the published event. If you want only one instance to handle the event, you cannot use pure Pub/Sub. You should look into Redis Streams or consumer groups instead.

Frequently Asked Questions

Can I use Redis Pub/Sub for chat applications?

Yes, it’s perfect for chat because it delivers messages instantly to thousands of connected clients. Pair it with WebSockets to broadcast messages across multiple server instances.

What happens if my subscriber goes offline?

Any messages published while your subscriber is offline are permanently lost. If you need message persistence, consider using Redis Streams or a specialized queue broker instead.

Is there a limit to the number of channels I can create?

No, there’s no hard limit on the number of channels. Redis handles millions of channels efficiently because they don’t consume memory unless there are active subscribers listening to them.

How do I secure my Redis Pub/Sub connections?

Always enable TLS for your Redis connections in production and use Access Control Lists (ACLs) to restrict your subscriber clients to specific channel patterns.

Next Steps for Your Event Broker

Now that you have a working event broker, you can start decoupling your core business logic. A great next step is to protect your critical write operations by building a Redis-backed idempotency middleware. This ensures that even if an event is delivered twice due to network retries, your database remains consistent.

For more advanced patterns, check out the Node.js EventEmitter API docs to learn how to integrate Redis Pub/Sub directly with native Node.js event patterns.

all_in_one_marketing_tool