How to Build a Real-Time Notification System With Redis Pub/Sub

Spin up two Node.js server instances behind a load balancer and your basic WebSocket notification system will immediately start dropping messages. A user connected to Instance A will never receive an event published by Instance B — because the two processes share no memory. This is the wall most tutorials never address.

The fix is a message broker that all instances can speak to. Redis Pub/Sub is the pragmatic choice: it is fast, operationally simple, and already present in most production stacks as a cache. This guide walks through building a notification system that actually survives horizontal scaling.


Why Redis Pub/Sub, Not Just WebSockets Alone

WebSockets solve the client-server half of the problem — they keep a persistent, bidirectional channel open between a browser and one server process. The gap appears on the server side: when a microservice or background worker fires an event, it has no way to know which server instance holds the target user's socket connection.

Redis Pub/Sub fills that gap. Every server instance subscribes to a shared Redis channel. When any part of your backend publishes a notification event, Redis fans it out to every subscriber simultaneously. Each instance then checks whether the intended recipient is connected to it and, if so, pushes the message down the WebSocket.

This is a classic fan-out-then-filter pattern. It is not the only approach — Kafka, NATS, or even PostgreSQL LISTEN/NOTIFY are valid alternatives — but Redis wins on simplicity and latency for most SaaS notification workloads.


System Architecture at a Glance

Browser ──WS──► Node Instance A ──subscribe──► Redis
                                                  │
Background Job ──publish──────────────────────────┘
                                                  │
               Node Instance B ──subscribe────────┘
                    │
               Browser ──WS──► (target user is here)

The background job does not care which instance holds the socket. It publishes once. Redis delivers to all. Each instance discards events irrelevant to its connected clients.


Setting Up the Project

You will need Node.js 18+, the ws library for WebSockets, and ioredis for Redis connectivity.

npm init -y
npm install ws ioredis

Use two separate ioredis clients — one dedicated publisher and one dedicated subscriber. This is a hard Redis requirement: a client in subscriber mode can only issue subscription commands, not general-purpose ones.

// redis.js
import Redis from "ioredis";

export const publisher = new Redis({ host: "localhost", port: 6379 });
export const subscriber = new Redis({ host: "localhost", port: 6379 });

Managing WebSocket Connections Per Instance

Each server instance maintains an in-memory map of userId → WebSocket. When a client connects, it sends an auth token; the server resolves it to a user ID and registers the socket.

// server.js
import { WebSocketServer } from "ws";
import { subscriber, publisher } from "./redis.js";

const clients = new Map(); // userId → ws

const wss = new WebSocketServer({ port: process.env.PORT || 8080 });

wss.on("connection", (ws, req) => {
  const userId = resolveUserFromRequest(req); // your auth logic
  clients.set(userId, ws);

  ws.on("close", () => clients.delete(userId));
});

// Subscribe to the shared notifications channel
subscriber.subscribe("notifications", (err) => {
  if (err) console.error("Subscription failed:", err);
});

subscriber.on("message", (channel, raw) => {
  if (channel !== "notifications") return;

  const { targetUserId, payload } = JSON.parse(raw);
  const ws = clients.get(targetUserId);

  if (ws && ws.readyState === ws.OPEN) {
    ws.send(JSON.stringify(payload));
  }
});

This is the core logic. The message handler runs on every instance. Most of the time clients.get(targetUserId) returns undefined — the target is on a different instance — and the event is silently discarded. That is expected and correct.


Publishing Notifications From Anywhere

Any service — an API handler, a queue worker, a scheduled job — can fire a notification with a single publish call:

await publisher.publish(
  "notifications",
  JSON.stringify({
    targetUserId: "user_abc123",
    payload: {
      type: "ORDER_SHIPPED",
      message: "Your order #4821 has been shipped.",
      timestamp: Date.now(),
    },
  })
);

No knowledge of which server instance holds the socket is required. Redis handles the delivery.


Handling Edge Cases in Production

User Not Currently Connected

If the user is offline when the event fires, the notification is lost. Solve this by persisting undelivered notifications to a database (Postgres, MongoDB) and flushing them to the client on reconnect. A simple GET /notifications/pending endpoint polled on WebSocket open is enough for most products.

Reconnect and Message Ordering

Browsers drop connections. When a client reconnects, replay any messages with a timestamp after the client's last acknowledged event. Store a lastSeen cursor per user in Redis or your database.

Redis Failover

If your Redis instance goes down, all pub/sub traffic stops. Use Redis Sentinel or Redis Cluster for high-availability deployments. For most early-stage SaaS products, a managed Redis service (Redis Cloud, AWS ElastiCache) with automatic failover is the right trade-off.

Payload Size Discipline

Keep pub/sub payloads small — IDs and event types, not full data blobs. Let the client fetch full details via a REST or GraphQL call if needed. This keeps Redis throughput high and your channel snappy.


Scaling Beyond Redis Pub/Sub

Redis Pub/Sub is an at-most-once delivery model. If a subscriber is momentarily disconnected when a message is published, it misses that message — Redis does not persist channel events. For systems where every notification must be delivered regardless of subscriber state, graduate to Redis Streams or a dedicated message queue like BullMQ backed by Redis. These provide consumer groups, acknowledgements, and replay — at the cost of added complexity.

For the majority of SaaS notification systems — new messages, activity alerts, system events — the at-most-once model combined with a database-backed pending queue is more than sufficient.


Why This Matters for Your Project

If you are building any SaaS product with real-time features — collaborative tools, logistics dashboards, fintech alerts, order tracking — the gap between a single-server prototype and a horizontally scalable production system is exactly this layer. Getting Redis Pub/Sub right early means you can add server instances under load without rewriting your notification logic, and your users never silently miss events. The architecture described here is minimal, observable, and straightforward to extend as your user base grows.