Skip to content
Technologies

Redis Pub/Sub - Real-Time Notifications

Published on:
·7 min read·Author: MDS Software Solutions Group

Redis Pub/Sub Real-Time

technologie

Redis Pub/Sub - Real-Time Notifications

Modern web applications demand instant delivery of information to users. Chat systems, message notifications, live stock prices, real-time sports scores - all of these features share one common requirement: real-time communication. Redis Pub/Sub is a pattern that enables building such systems in a simple, performant, and scalable way.

In this article, we will explore the Publish/Subscribe mechanism in Redis in depth, demonstrate how to integrate it with WebSockets, compare it with Redis Streams and alternative solutions like RabbitMQ and Apache Kafka, and finally build a complete notification system in Node.js.

What is the Pub/Sub Pattern?#

The Publish/Subscribe (Pub/Sub) pattern is an asynchronous communication model where message senders (publishers) do not send messages directly to specific receivers (subscribers). Instead, messages are published to channels, and receivers subscribe to those channels to receive messages they are interested in.

Key characteristics of the Pub/Sub pattern:

  • Loose coupling - the sender does not need to know who receives the message
  • One-to-many communication - a single message reaches multiple receivers
  • Asynchronous - the sender does not wait for a response from the receiver
  • Dynamic - receivers can join and leave at any time

How Redis Pub/Sub Works#

Redis implements the Pub/Sub pattern as a native feature. The Redis server acts as a message broker, managing channels and delivering messages to all active subscribers.

Channels#

Channels in Redis are named message streams. They do not need to be created explicitly - they come into existence automatically when the first subscriber connects or when the first message is published. Channel names are arbitrary strings, but a good practice is to use a hierarchical naming convention with separators:

notifications:user:123
chat:room:general
orders:status:updated
system:alerts

Publishers#

A publisher is a Redis client that sends messages to a specific channel using the PUBLISH command:

PUBLISH notifications:user:123 '{"type":"new_message","from":"John","text":"Hello!"}'

The command returns the number of subscribers who received the message. If no one is listening, the message is lost forever. This is a crucial difference from message queues.

Subscribers#

A subscriber is a Redis client that listens on one or more channels:

SUBSCRIBE notifications:user:123 chat:room:general

Redis also supports pattern-based subscriptions:

PSUBSCRIBE notifications:user:* chat:room:*

This allows a single subscriber to listen on multiple channels matching a pattern, which is extremely useful in notification systems.

Basic Node.js Example#

Let's start with a simple example using the ioredis library:

import Redis from 'ioredis';

// Subscriber - listens on a channel
const subscriber = new Redis({ host: '127.0.0.1', port: 6379 });

subscriber.subscribe('notifications:global', (err, count) => {
  if (err) {
    console.error('Subscription error:', err);
    return;
  }
  console.log(`Subscribed to ${count} channels`);
});

subscriber.on('message', (channel, message) => {
  const data = JSON.parse(message);
  console.log(`[${channel}] Received:`, data);
});

// Publisher - sends messages
const publisher = new Redis({ host: '127.0.0.1', port: 6379 });

async function sendNotification(userId, notification) {
  const message = JSON.stringify({
    id: crypto.randomUUID(),
    userId,
    ...notification,
    timestamp: Date.now(),
  });

  const receivers = await publisher.publish(
    `notifications:user:${userId}`,
    message
  );
  console.log(`Message delivered to ${receivers} subscribers`);
}

// Send an example notification
sendNotification('123', {
  type: 'order_update',
  title: 'Order Shipped',
  body: 'Your order #4567 has been shipped.',
});

Important: A Redis client in subscriber mode cannot execute other commands (GET, SET, etc.). This is why you always need separate connections for the publisher and the subscriber.

WebSocket Integration - Real-Time Architecture#

To deliver notifications from Redis to the user's browser, we need a transport layer. WebSocket is the ideal choice - it provides bidirectional, persistent communication between the server and the client.

System Architecture#

[Browser]     <--WebSocket--> [Node.js Server] <--Redis Pub/Sub--> [Redis]
                                                                      ^
[Browser]     <--WebSocket--> [Node.js Server] <--Redis Pub/Sub--> ---|
                                                                      |
                              [Microservice A] ----PUBLISH----------> |
                              [Microservice B] ----PUBLISH----------> |

Each Node.js server subscribes to Redis channels and forwards messages to connected WebSocket clients. Microservices publish events to Redis channels, which reach all servers, which in turn deliver them to the appropriate browsers.

Complete Server Implementation#

import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import Redis from 'ioredis';
import express from 'express';

const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ server });

// Redis - separate connections for sub and pub
const redisSub = new Redis({ host: '127.0.0.1', port: 6379 });
const redisPub = new Redis({ host: '127.0.0.1', port: 6379 });

// Map: userId -> Set<WebSocket>
const userConnections = new Map();

// WebSocket connection management
wss.on('connection', (ws, req) => {
  const userId = authenticateUser(req); // JWT / session
  if (!userId) {
    ws.close(4001, 'Unauthorized');
    return;
  }

  // Add connection to the map
  if (!userConnections.has(userId)) {
    userConnections.set(userId, new Set());
    // Subscribe to the user's channel only on first connection
    redisSub.subscribe(`notifications:user:${userId}`);
  }
  userConnections.get(userId).add(ws);

  console.log(`User ${userId} connected. Active connections: ${userConnections.get(userId).size}`);

  // Handle messages from client
  ws.on('message', async (data) => {
    try {
      const msg = JSON.parse(data.toString());
      await handleClientMessage(userId, msg);
    } catch (err) {
      ws.send(JSON.stringify({ error: 'Invalid message' }));
    }
  });

  // Cleanup on disconnect
  ws.on('close', () => {
    const connections = userConnections.get(userId);
    if (connections) {
      connections.delete(ws);
      if (connections.size === 0) {
        userConnections.delete(userId);
        redisSub.unsubscribe(`notifications:user:${userId}`);
      }
    }
  });

  // Send connection confirmation
  ws.send(JSON.stringify({ type: 'connected', userId }));
});

// Handle messages from Redis
redisSub.on('message', (channel, message) => {
  // Extract userId from channel name
  const match = channel.match(/^notifications:user:(.+)$/);
  if (!match) return;

  const userId = match[1];
  const connections = userConnections.get(userId);

  if (connections) {
    connections.forEach((ws) => {
      if (ws.readyState === ws.OPEN) {
        ws.send(message);
      }
    });
  }
});

// Subscribe to global channels
redisSub.subscribe('notifications:global');
redisSub.on('message', (channel, message) => {
  if (channel === 'notifications:global') {
    // Broadcast to all connected users
    wss.clients.forEach((ws) => {
      if (ws.readyState === ws.OPEN) {
        ws.send(message);
      }
    });
  }
});

// API for sending notifications
app.post('/api/notify', express.json(), async (req, res) => {
  const { userId, type, title, body } = req.body;

  const notification = JSON.stringify({
    id: crypto.randomUUID(),
    type,
    title,
    body,
    timestamp: Date.now(),
  });

  const channel = userId
    ? `notifications:user:${userId}`
    : 'notifications:global';

  const receivers = await redisPub.publish(channel, notification);
  res.json({ delivered: receivers });
});

async function handleClientMessage(userId, msg) {
  if (msg.type === 'mark_read') {
    await redisPub.publish(
      `notifications:user:${userId}`,
      JSON.stringify({ type: 'notification_read', id: msg.notificationId })
    );
  }
}

function authenticateUser(req) {
  // Authentication implementation - JWT, cookie session, etc.
  const url = new URL(req.url, `http://${req.headers.host}`);
  return url.searchParams.get('userId');
}

server.listen(3000, () => {
  console.log('Server running on port 3000');
});

Browser WebSocket Client#

class NotificationClient {
  constructor(userId) {
    this.userId = userId;
    this.listeners = new Map();
    this.reconnectAttempts = 0;
    this.maxReconnectAttempts = 10;
    this.connect();
  }

  connect() {
    this.ws = new WebSocket(`ws://localhost:3000?userId=${this.userId}`);

    this.ws.onopen = () => {
      console.log('Connected to notification server');
      this.reconnectAttempts = 0;
    };

    this.ws.onmessage = (event) => {
      const notification = JSON.parse(event.data);
      this.emit(notification.type, notification);
    };

    this.ws.onclose = () => {
      if (this.reconnectAttempts < this.maxReconnectAttempts) {
        const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 30000);
        this.reconnectAttempts++;
        setTimeout(() => this.connect(), delay);
      }
    };
  }

  on(type, callback) {
    if (!this.listeners.has(type)) {
      this.listeners.set(type, new Set());
    }
    this.listeners.get(type).add(callback);
  }

  emit(type, data) {
    const callbacks = this.listeners.get(type);
    if (callbacks) {
      callbacks.forEach((cb) => cb(data));
    }
  }

  markAsRead(notificationId) {
    this.ws.send(JSON.stringify({
      type: 'mark_read',
      notificationId,
    }));
  }
}

// Usage
const client = new NotificationClient('123');

client.on('order_update', (notification) => {
  showToast(notification.title, notification.body);
});

client.on('new_message', (notification) => {
  updateChatBadge(notification);
});

Redis Streams vs Pub/Sub - Which One to Choose?#

Redis offers two mechanisms for real-time communication: Pub/Sub and Streams. While they may seem similar, they differ fundamentally.

Redis Pub/Sub#

  • Fire-and-forget - messages are not stored
  • No history - a new subscriber will not receive old messages
  • No acknowledgment - no ACK mechanism
  • Simplicity - minimal overhead, maximum performance
  • Ideal for: notifications, chat, live updates

Redis Streams#

  • Persistence - messages are stored in a data structure
  • Consumer Groups - parallel processing with acknowledgments
  • History - ability to read old messages
  • At-least-once - delivery guarantee
  • Ideal for: task queues, event sourcing, audit trails
// Redis Streams - example
// Add a message to the stream
await redis.xadd('orders:events', '*',
  'action', 'created',
  'orderId', '4567',
  'userId', '123'
);

// Read from a consumer group
await redis.xreadgroup(
  'GROUP', 'order-processors', 'worker-1',
  'COUNT', 10,
  'BLOCK', 5000,
  'STREAMS', 'orders:events', '>'
);

// Acknowledge processing
await redis.xack('orders:events', 'order-processors', messageId);

When to choose what?

| Scenario | Pub/Sub | Streams | |---|---|---| | Push notifications | Yes | No | | Live chat | Yes | Yes (with history) | | Task queue | No | Yes | | Event sourcing | No | Yes | | Live dashboard | Yes | No | | Log processing | No | Yes |

In practice, the best approach is often a combination of both. Pub/Sub for instant delivery, Streams for persistence and state recovery.

Scaling with Redis Cluster#

When a single Redis server is not enough, Redis Cluster allows load distribution. However, Pub/Sub in a cluster works differently than in standalone mode.

Sharded Pub/Sub (Redis 7.0+)#

Since version 7.0, Redis introduced Sharded Pub/Sub, which solves the scalability problem:

import Redis from 'ioredis';

const cluster = new Redis.Cluster([
  { host: '10.0.0.1', port: 6379 },
  { host: '10.0.0.2', port: 6379 },
  { host: '10.0.0.3', port: 6379 },
]);

// Sharded publish - message goes only to the node responsible for the channel
await cluster.spublish('notifications:user:123', JSON.stringify(payload));

// Sharded subscribe
cluster.ssubscribe('notifications:user:123', (err) => {
  if (err) console.error(err);
});

cluster.on('smessage', (channel, message) => {
  console.log(`Sharded message on ${channel}:`, message);
});

In classic Pub/Sub, every message is broadcast to all nodes in the cluster. Sharded Pub/Sub routes messages only to the node managing the relevant hash slot, dramatically reducing network traffic.

Scaling Strategy#

[Load Balancer]
    |-- [Node.js Server 1] --\
    |-- [Node.js Server 2] ---|-- Redis Cluster
    \-- [Node.js Server 3] --/    |-- Node A (slots 0-5460)
                                   |-- Node B (slots 5461-10922)
                                   \-- Node C (slots 10923-16383)

Each Node.js server maintains a connection to the Redis cluster. Users can be connected to any server - Pub/Sub ensures the message reaches the right place.

Use Cases#

1. Real-Time Chat#

Redis Pub/Sub is perfectly suited for chat systems. Each chat room is a separate channel:

// Join a room
async function joinRoom(userId, roomId) {
  await redisSub.subscribe(`chat:room:${roomId}`);
  await redisPub.publish(`chat:room:${roomId}`, JSON.stringify({
    type: 'user_joined',
    userId,
    timestamp: Date.now(),
  }));
}

// Send a message
async function sendMessage(userId, roomId, text) {
  const message = {
    id: crypto.randomUUID(),
    type: 'message',
    userId,
    roomId,
    text,
    timestamp: Date.now(),
  };

  // Save to Redis Streams (persistence + history)
  await redis.xadd(`chat:history:${roomId}`, '*',
    'data', JSON.stringify(message)
  );

  // Send via Pub/Sub (real-time delivery)
  await redisPub.publish(`chat:room:${roomId}`, JSON.stringify(message));
}

2. Live Updates (Dashboards, Monitoring)#

Real-time updates for business dashboards, monitoring systems, or scoreboards:

// Metrics microservice publishes data every second
setInterval(async () => {
  const metrics = {
    cpu: os.loadavg()[0],
    memory: process.memoryUsage().heapUsed,
    activeUsers: await redis.scard('active:users'),
    requestsPerSec: await redis.get('metrics:rps'),
  };

  await redisPub.publish('dashboard:metrics', JSON.stringify(metrics));
}, 1000);

3. Gaming - Multiplayer and Leaderboards#

In multiplayer games, Redis Pub/Sub handles game state synchronization:

// Update player position
async function updatePlayerPosition(gameId, playerId, position) {
  await redisPub.publish(`game:${gameId}:state`, JSON.stringify({
    type: 'player_move',
    playerId,
    position,
    timestamp: Date.now(),
  }));
}

// Update leaderboard
async function updateLeaderboard(gameId, playerId, score) {
  await redis.zadd(`leaderboard:${gameId}`, score, playerId);
  const topPlayers = await redis.zrevrange(
    `leaderboard:${gameId}`, 0, 9, 'WITHSCORES'
  );

  await redisPub.publish(`game:${gameId}:leaderboard`, JSON.stringify({
    type: 'leaderboard_update',
    topPlayers,
  }));
}

Comparison with RabbitMQ and Apache Kafka#

Redis Pub/Sub is not the only solution for asynchronous communication. Let's compare it with the two most popular alternatives.

Redis Pub/Sub vs RabbitMQ#

| Feature | Redis Pub/Sub | RabbitMQ | |---|---|---| | Model | Pub/Sub (fire-and-forget) | Queue + Exchange (with acknowledgments) | | Persistence | None | Yes (durable queues) | | Routing | Simple (channels, patterns) | Advanced (direct, topic, fanout, headers) | | Protocol | RESP (Redis protocol) | AMQP 0.9.1 | | Latency | Ultra-low (~0.1ms) | Low (~1ms) | | Throughput | ~1M msg/s | ~50K msg/s | | Delivery guarantee | At-most-once | At-least-once / Exactly-once | | Operational complexity | Low | Medium |

Choose Redis Pub/Sub when: you need minimal latency, can accept message loss, and already have Redis in your stack.

Choose RabbitMQ when: you need delivery guarantees, advanced routing, priority queues, or dead-letter queues.

Redis Pub/Sub vs Apache Kafka#

| Feature | Redis Pub/Sub | Apache Kafka | |---|---|---| | Storage | None | Persistent (log retention) | | Throughput | ~1M msg/s | ~1M+ msg/s (partitioning) | | Latency | ~0.1ms | ~5-10ms | | Replay | Not possible | Full (offset-based) | | Consumer Groups | None | Native | | Scaling | Redis Cluster | Partitions + brokers | | Complexity | Minimal | High (ZooKeeper/KRaft) | | Use case | Real-time notifications | Event streaming, data pipelines |

Choose Redis Pub/Sub when: you are building a notification system, chat, or live updates - where simplicity and latency matter most.

Choose Kafka when: you are building data pipelines, event sourcing, need replay and durability, or process millions of events per minute.

When to Use Redis Streams Instead of Either?#

Redis Streams is a middle ground - it offers persistence and consumer groups like Kafka, but with the simplicity of Redis. For many use cases, it is a sufficient solution that eliminates the need for additional infrastructure.

Design Patterns and Best Practices#

1. Channel Naming Convention#

{domain}:{entity}:{identifier}:{event}

notifications:user:123:new_message
orders:status:4567:updated
chat:room:general:message
system:health:server-1:alert

2. Message Structure#

const messageSchema = {
  id: 'uuid-v4',           // Unique message ID
  type: 'string',          // Event type
  source: 'string',        // Source (microservice)
  timestamp: 'number',     // Unix timestamp
  version: 'number',       // Schema version
  data: {},                // Payload
  metadata: {},            // Additional information
};

3. Error Handling and Reconnection#

const redisSub = new Redis({
  host: '127.0.0.1',
  port: 6379,
  retryStrategy(times) {
    const delay = Math.min(times * 100, 3000);
    return delay;
  },
  maxRetriesPerRequest: null,
  enableReadyCheck: true,
});

redisSub.on('error', (err) => {
  console.error('Redis subscriber error:', err);
});

redisSub.on('reconnecting', (delay) => {
  console.log(`Reconnecting to Redis in ${delay}ms...`);
});

4. Monitoring and Metrics#

// Monitor subscriber count
async function getChannelStats() {
  const info = await redisPub.pubsub('NUMSUB',
    'notifications:global',
    'chat:room:general'
  );

  // info = ['notifications:global', 5, 'chat:room:general', 12]
  return info;
}

// Monitor active channel count
async function getActiveChannels() {
  const channels = await redisPub.pubsub('CHANNELS', 'notifications:*');
  return channels.length;
}

Conclusion#

Redis Pub/Sub is a powerful yet simple tool for building real-time communication systems. Key takeaways:

  1. Pub/Sub is fire-and-forget - ideal for notifications, chat, live updates
  2. Streams complement Pub/Sub - when you need persistence and history
  3. WebSocket + Redis Pub/Sub - is the standard for real-time architecture
  4. Sharded Pub/Sub (Redis 7.0+) - solves scaling challenges
  5. Redis does not replace Kafka - they are different tools for different problems

Redis Pub/Sub works best as a delivery layer in a microservice architecture where you need minimal latency and deployment simplicity.


Need a Real-Time Notification System?#

At MDS Software Solutions Group, we design and implement scalable real-time communication systems. From chat applications, through alert systems, to live dashboards - we leverage Redis Pub/Sub, WebSocket, and proven architectural patterns to deliver solutions that perform reliably under heavy load.

Get in touch with us - we will help you choose the right technology and build a system tailored to your needs.

Author
MDS Software Solutions Group

Team of programming experts specializing in modern web technologies.

Redis Pub/Sub - Real-Time Notifications | MDS Software Solutions Group | MDS Software Solutions Group