Real-time notifications are one of those features that seem simple on the surface — "just send a message when something happens" — and turn into an architectural puzzle the moment you try to scale them. A single server maintaining WebSocket connections is easy. The problem starts when you have multiple server instances and a WebSocket connection on Server A needs to deliver a message triggered by an event on Server B.
This tutorial walks through building a production-grade notification system that solves exactly that problem using Node.js, the ws library, and Redis Pub/Sub as a message broker across instances.
What We're Building
- A WebSocket server that maintains persistent client connections
- A Redis Pub/Sub channel as a cross-instance message bus
- A REST endpoint to trigger notifications (simulating an internal service)
- Automatic reconnection logic on the client side
- TypeScript throughout
By the end you'll have a working system where publishing a notification to any server instance delivers it to the correct connected client regardless of which instance holds their WebSocket connection.
The Architecture
Client A ──WebSocket──► Server Instance 1 ──Subscribe──► Redis Pub/Sub Channel
Client B ──WebSocket──► Server Instance 2 ──Subscribe──► │
│
Any Service ──POST /notify──► Any Instance ──Publish──────────┘
Every server instance subscribes to the same Redis channel. When any instance receives a "publish" event (from an internal API call, a job queue, a webhook — whatever), it broadcasts to Redis. Every instance receives that message and checks: do I have a WebSocket connection for this user? If yes, deliver it.
This pattern scales horizontally with zero coordination between instances.
Prerequisites
- Node.js 18+
- Docker (for local Redis)
- Basic familiarity with WebSockets and async/await
Step 1: Setup
mkdir realtime-notifications && cd realtime-notifications
npm init -y
npm install ws redis express
npm install -D typescript @types/ws @types/express @types/node ts-node
npx tsc --init
Update tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"outDir": "./dist"
}
}
Start Redis:
docker run -d --name redis-notify -p 6379:6379 redis:7-alpine
Step 2: The Connection Registry
Each server instance needs to track which WebSocket connections it's currently holding, keyed by user ID. This is purely in-memory — it only needs to know about its own connections.
Create src/connectionRegistry.ts:
import WebSocket from 'ws';
// In-memory map of userId → Set of WebSocket connections
// A user can have multiple open tabs/devices
const registry = new Map<string, Set<WebSocket>>();
export function registerConnection(userId: string, ws: WebSocket): void {
if (!registry.has(userId)) {
registry.set(userId, new Set());
}
registry.get(userId)!.add(ws);
console.log(`[Registry] User ${userId} connected. Total connections: ${getConnectionCount()}`);
}
export function removeConnection(userId: string, ws: WebSocket): void {
const connections = registry.get(userId);
if (!connections) return;
connections.delete(ws);
if (connections.size === 0) {
registry.delete(userId);
}
console.log(`[Registry] User ${userId} disconnected. Total connections: ${getConnectionCount()}`);
}
export function getConnections(userId: string): Set<WebSocket> | undefined {
return registry.get(userId);
}
export function getConnectionCount(): number {
let count = 0;
registry.forEach((connections) => (count += connections.size));
return count;
}
Why a Set per user instead of a single connection? A user on three devices has three WebSocket connections. You want a notification to reach all of them.
Step 3: The Notification Type
Create src/types.ts:
export interface Notification {
id: string;
userId: string; // Recipient
type: 'info' | 'warning' | 'success' | 'error';
title: string;
message: string;
timestamp: string;
metadata?: Record<string, unknown>;
}
Step 4: The Redis Pub/Sub Bridge
This is the core of the cross-instance communication. We need two separate Redis clients: one for publishing, one for subscribing. This is a Redis requirement — a client in subscriber mode can't issue regular commands.
Create src/redisBridge.ts:
import { createClient, RedisClientType } from 'redis';
import { Notification } from './types';
import { getConnections } from './connectionRegistry';
const CHANNEL = 'notifications';
let publisherClient: RedisClientType;
let subscriberClient: RedisClientType;
export async function initRedisBridge(): Promise<void> {
publisherClient = createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379' });
subscriberClient = createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379' });
publisherClient.on('error', (err) => console.error('[Redis Publisher] Error:', err.message));
subscriberClient.on('error', (err) => console.error('[Redis Subscriber] Error:', err.message));
await publisherClient.connect();
await subscriberClient.connect();
// Subscribe to the notifications channel
await subscriberClient.subscribe(CHANNEL, (message) => {
handleIncomingMessage(message);
});
console.log(`[RedisBridge] Listening on channel: ${CHANNEL}`);
}
function handleIncomingMessage(rawMessage: string): void {
let notification: Notification;
try {
notification = JSON.parse(rawMessage);
} catch {
console.error('[RedisBridge] Failed to parse message:', rawMessage);
return;
}
// Check if THIS instance has a connection for this user
const connections = getConnections(notification.userId);
if (!connections || connections.size === 0) {
// This instance doesn't hold a connection for this user — that's fine,
// another instance will handle it
return;
}
// Deliver to all of this user's connections on this instance
const payload = JSON.stringify(notification);
for (const ws of connections) {
if (ws.readyState === ws.OPEN) {
ws.send(payload);
console.log(`[RedisBridge] Delivered notification ${notification.id} to user ${notification.userId}`);
}
}
}
export async function publishNotification(notification: Notification): Promise<void> {
const payload = JSON.stringify(notification);
await publisherClient.publish(CHANNEL, payload);
console.log(`[RedisBridge] Published notification ${notification.id} for user ${notification.userId}`);
}
The key insight in handleIncomingMessage: every instance receives every published message, but only the instance holding that user's WebSocket connection will actually have something to send. The others will hit the early return and do nothing. No coordination required.
Step 5: The WebSocket Server
Create src/wsServer.ts:
import { WebSocketServer, WebSocket } from 'ws';
import { IncomingMessage } from 'http';
import { registerConnection, removeConnection } from './connectionRegistry';
// In a real system, validate a JWT token here
// For this demo, we accept userId as a query parameter
function extractUserId(req: IncomingMessage): string | null {
const url = new URL(req.url || '', `http://localhost`);
return url.searchParams.get('userId');
}
export function initWebSocketServer(port: number): void {
const wss = new WebSocketServer({ port });
wss.on('connection', (ws: WebSocket, req: IncomingMessage) => {
const userId = extractUserId(req);
if (!userId) {
ws.close(1008, 'userId is required');
return;
}
registerConnection(userId, ws);
// Send an acknowledgment on connection
ws.send(JSON.stringify({
type: 'connected',
message: `Connected as ${userId}`,
timestamp: new Date().toISOString(),
}));
ws.on('close', () => {
removeConnection(userId, ws);
});
ws.on('error', (err) => {
console.error(`[WebSocket] Error for user ${userId}:`, err.message);
removeConnection(userId, ws);
});
// Handle ping/pong for connection health checks
ws.on('pong', () => {
(ws as any).isAlive = true;
});
});
// Heartbeat interval — detect and clean up zombie connections
const heartbeat = setInterval(() => {
wss.clients.forEach((ws) => {
if ((ws as any).isAlive === false) {
ws.terminate();
return;
}
(ws as any).isAlive = false;
ws.ping();
});
}, 30_000);
wss.on('close', () => clearInterval(heartbeat));
console.log(`[WebSocket] Server listening on port ${port}`);
}
The heartbeat interval is important in production. TCP connections can appear open to your server while the client is actually gone (network dropout, mobile device sleeping). Sending a periodic ping and terminating connections that don't respond prevents accumulating ghost connections that hold memory and skew your connection count metrics.
Step 6: The REST API for Publishing Notifications
Create src/apiServer.ts:
import express from 'express';
import { v4 as uuidv4 } from 'uuid';
import { publishNotification } from './redisBridge';
import { Notification } from './types';
// npm install uuid && npm install -D @types/uuid
export function initApiServer(port: number): void {
const app = express();
app.use(express.json());
// Internal endpoint — in production, protect with API key or internal network policy
app.post('/notify', async (req, res) => {
const { userId, type, title, message, metadata } = req.body;
if (!userId || !type || !title || !message) {
return res.status(400).json({ error: 'userId, type, title, and message are required' });
}
const notification: Notification = {
id: uuidv4(),
userId,
type,
title,
message,
timestamp: new Date().toISOString(),
metadata,
};
await publishNotification(notification);
res.status(202).json({ id: notification.id, status: 'published' });
});
app.get('/health', (_, res) => res.json({ status: 'ok' }));
app.listen(port, () => console.log(`[API] Server listening on port ${port}`));
}
Note the 202 Accepted response rather than 200 OK. Publishing to Redis is fire-and-forget — we don't wait for delivery confirmation from the WebSocket layer. A 202 correctly signals that we've accepted the request and will process it asynchronously.
Step 7: The Entry Point
Create src/index.ts:
import { initRedisBridge } from './redisBridge';
import { initWebSocketServer } from './wsServer';
import { initApiServer } from './apiServer';
async function start() {
await initRedisBridge();
initWebSocketServer(8080);
initApiServer(3000);
console.log('[App] Notification server ready');
}
start().catch((err) => {
console.error('[App] Fatal startup error:', err);
process.exit(1);
});
Step 8: Test It End-to-End
Start the server:
npx ts-node src/index.ts
Open a WebSocket connection (using websocat or the browser console):
# Install websocat: https://github.com/vi/websocat
websocat ws://localhost:8080?userId=user-123
You should see the connection acknowledgment:
{"type":"connected","message":"Connected as user-123","timestamp":"..."}
Now trigger a notification from another terminal:
curl -X POST http://localhost:3000/notify \
-H "Content-Type: application/json" \
-d '{
"userId": "user-123",
"type": "success",
"title": "Payment Received",
"message": "Your payment of $49.00 has been processed.",
"metadata": { "invoiceId": "INV-2024-0042" }
}'
The notification should appear immediately in your WebSocket terminal.
Step 9: Verify Cross-Instance Delivery
To prove the multi-instance routing works, run two server instances on different ports:
# Terminal 1
PORT=3000 WS_PORT=8080 npx ts-node src/index.ts
# Terminal 2 (you'd update index.ts to accept PORT/WS_PORT env vars)
PORT=3001 WS_PORT=8081 npx ts-node src/index.ts
Connect your WebSocket client to instance 1 (port 8080), then send the POST /notify request to instance 2 (port 3001). The notification still arrives via instance 1. Redis does the routing.
What to Add Before Production
Persistent notification history. Right now, a notification sent while a user is offline is lost. Store notifications in a database (Postgres with a notifications table works well) and fetch unread ones on WebSocket connect.
Authentication. Replace the ?userId= query param with a signed JWT. Validate it before registering the connection and extract the user ID from the token claims.
Notification acknowledgment. Add a client-to-server message ({ type: 'ack', notificationId: '...' }) so users can mark notifications as read and you can track delivery.
Monitoring. Instrument connection count, messages published per second, and Redis publish latency. A spike in connection count without a corresponding traffic spike often indicates reconnection loops — worth alerting on.
Conclusion
The Redis Pub/Sub pattern is elegant for this use case because it adds almost no complexity — it's just a broadcast channel every instance listens to. Each instance independently decides whether it has something to do with each message, and those that don't simply move on.
It won't serve every use case: if you need guaranteed delivery, ordered delivery, or delivery acknowledgment at the broker level, you'll want Kafka or a dedicated message queue. But for real-time notifications at typical web application scale, this architecture is reliable, understandable, and horizontally scalable without any instance coordination.
Top comments (0)