A single-server WebSocket demo is easy to build and completely misleading. The moment you scale your Node.js app to two instances behind a load balancer, half your users stop receiving notifications — because their WebSocket connection lives on one server, and your event fired on another. This is the problem Redis Pub/Sub solves, and it is the difference between a toy prototype and a production-grade notification system.

This guide walks through the full architecture: from establishing WebSocket connections to fanning out events across every server instance in your fleet.


The Core Problem With Naive WebSocket Scaling

When a user connects via WebSocket, that persistent connection is held in memory on whichever server handled the handshake. If you have three server instances and a user on Instance A triggers an event that should notify a user connected to Instance B, Instance B has no awareness of that event.

Most tutorials never address this because they run everything on localhost. In production, you need a message broker that all instances subscribe to — a shared nervous system. Redis Pub/Sub is the standard, battle-tested choice for this pattern.


Architecture Overview

The system has four layers:

  • Client — browser or mobile app holding a WebSocket connection
  • Node.js WebSocket servers — multiple stateless instances, each managing a pool of active connections
  • Redis Pub/Sub channel — the shared message bus all instances subscribe to
  • Event producers — your API endpoints, background workers, or webhooks that publish notification events

When an event is produced, it is published to a Redis channel. Every server instance receives that message via its subscription and delivers it to whichever connected clients it is responsible for.


Setting Up the WebSocket Server

Use the ws library — lightweight, production-safe, and framework-agnostic.

npm install ws ioredis
// server.js
const WebSocket = require("ws");
const Redis = require("ioredis");

const wss = new WebSocket.Server({ port: 8080 });

// One subscriber per server instance
const subscriber = new Redis({ host: "your-redis-host", port: 6379 });
const publisher  = new Redis({ host: "your-redis-host", port: 6379 });

// Map of userId → Set of WebSocket connections (a user may have multiple tabs)
const userConnections = new Map();

wss.on("connection", (ws, req) => {
  const userId = getUserIdFromRequest(req); // JWT decode, session lookup, etc.

  if (!userConnections.has(userId)) {
    userConnections.set(userId, new Set());
  }
  userConnections.get(userId).add(ws);

  ws.on("close", () => {
    userConnections.get(userId)?.delete(ws);
  });
});

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

subscriber.on("message", (channel, message) => {
  const { userId, payload } = JSON.parse(message);
  const connections = userConnections.get(userId);

  if (connections) {
    for (const ws of connections) {
      if (ws.readyState === WebSocket.OPEN) {
        ws.send(JSON.stringify(payload));
      }
    }
  }
});

Every instance runs this same code. Each has its own userConnections map, but all share the same Redis channel. When a message arrives on "notifications", every instance checks whether it holds a connection for the target user — and delivers if so.


Publishing Notifications From Anywhere

Your API server, a background job, or a webhook handler can now trigger a notification without caring which WebSocket instance the user is on:

// From any service that has access to Redis
async function notifyUser(userId, eventType, data) {
  const message = JSON.stringify({
    userId,
    payload: { type: eventType, data, timestamp: Date.now() },
  });
  await publisher.publish("notifications", message);
}

// Example usage
await notifyUser("user_42", "ORDER_SHIPPED", { orderId: "ORD-9981" });

One publish call reaches every server instance simultaneously. The instance holding user user_42's connection handles delivery. Instances without that connection silently ignore it. This is the fan-out pattern.


Handling Authentication and Security

A few non-negotiables for production:

  • Authenticate on connection handshake. Validate a JWT or session token in the connection event before registering the socket. Reject unauthenticated connections immediately.
  • Never trust the client for userId. Extract identity server-side from the verified token, not from a query parameter the client supplies.
  • Use TLS. Expose WebSockets over wss:// via your load balancer or reverse proxy (Nginx, AWS ALB). The Node.js server can run plain ws:// internally.
  • Implement heartbeats. Use ping/pong frames to detect and clean up stale connections so your userConnections map does not leak memory.

Persistence and Missed Notifications

Redis Pub/Sub is fire-and-forget. If a user is offline when a message is published, they miss it. For most SaaS apps, you need a hybrid approach:

  1. Publish to Redis Pub/Sub for instant delivery to connected clients.
  2. Write to a notifications table in your database simultaneously.
  3. On WebSocket reconnect, fetch unread notifications from the database and flush them to the client.

This dual-write pattern covers both the real-time path and the catch-up path without building a full message queue.


Scaling Considerations

Connection limits

A single Node.js process can comfortably hold tens of thousands of WebSocket connections. Monitor open file descriptors and tune your OS limits (ulimit -n) accordingly.

Redis connection count

Each server instance needs at least two Redis connections — one for subscribing, one for publishing. With 10 instances, that is 20 connections: well within Redis's defaults. Use ioredis connection pooling for the publisher if your publish volume is high.

Horizontal pod autoscaling

Because server instances are stateless from Redis's perspective (the shared state lives in Redis), you can scale instances up and down freely. New instances subscribe to the channel immediately on startup and are ready to serve connections within milliseconds.


Why This Matters for Your Project

If you are building any SaaS product — a project management tool, an e-commerce platform, a logistics dashboard — real-time updates are quickly becoming a baseline user expectation, not a premium feature. The Redis Pub/Sub fan-out pattern is what makes that capability survive beyond a single server, meaning it survives any meaningful level of user growth. Getting this architecture right early prevents a painful re-engineering effort later when you are scrambling to scale under load. Build the stateless WebSocket layer from day one, and your notification system will grow as effortlessly as the rest of your infrastructure.