Back to blog
Node.js
Advanced

Real-Time Notification System with Node.js, Redis & WebSockets

This guide walks you through designing and deploying a production‑ready real‑time notification system. You will master the core concepts, architecture, and practical implementation using Node.js, Redis pub/sub, and WebSocket servers.

August 27, 202522 min read

Introduction

Real‑time notifications have become a baseline expectation for modern web and mobile applications. Whether it is a chat message, a live dashboard update, or an alert when a background job finishes, users expect instantaneous feedback without refreshing the page. Building a system that delivers millions of notifications per second while remaining resilient, observable, and easy to maintain is a non‑trivial engineering challenge.

This article provides a complete, production‑ready blueprint for a scalable real‑time notification system built with Node.js, Redis, and WebSockets. We will cover the fundamental concepts, a high‑level architecture, a step‑by‑step implementation guide, real‑world usage patterns, production‑grade code samples, a comparison of alternative messaging back‑ends, best practices, common pitfalls, performance tuning, security hardening, deployment strategies, debugging techniques, and a comprehensive FAQ.

By the end of this guide you will be able to spin up a horizontally scalable notification service, integrate it with any front‑end framework, and operate it confidently in a cloud‑native environment.

Table of Contents

Core Concepts

WebSocket Protocol

WebSocket provides a full‑duplex communication channel over a single TCP connection. Unlike HTTP polling, the server can push data to the client at any moment, which is essential for low‑latency notifications. The protocol begins with an HTTP upgrade handshake and then switches to a binary frame based exchange.

Redis Pub/Sub

Redis Pub/Sub is a lightweight messaging paradigm where publishers send messages to channels and subscribers receive them in real time. It is ideal for fan‑out scenarios because a single message can be delivered to thousands of subscribers without the publisher knowing their identities.

Horizontal Scaling with Stateless Workers

To scale beyond a single Node.js process, the notification service must be stateless. Each worker holds only ephemeral WebSocket connections; durable state (user‑to‑socket mappings, message history) lives in Redis or a separate data store. This design allows you to add or remove workers behind a load balancer without session affinity.

Message Acknowledgment and Idempotency

In a distributed system, network partitions can cause duplicate deliveries. Designing messages with unique identifiers and making the consumer processing idempotent prevents side effects such as double‑counting a notification.

Architecture Overview

The system consists of four logical layers:

  1. API Gateway / Load Balancer – terminates TLS, performs rate limiting, and distributes incoming WebSocket upgrade requests across a pool of Node.js workers.
  2. WebSocket Workers (Node.js) – each worker maintains a set of open WebSocket connections, subscribes to relevant Redis channels, and forwards messages to the appropriate sockets.
  3. Redis Cluster – acts as the message broker (Pub/Sub) and stores auxiliary data such as user‑to‑socket mappings, presence sets, and recent message caches.
  4. Business Services – your existing microservices or monolith publish notification events to Redis channels (e.g., notifications:user:123) whenever a relevant domain event occurs.

Data flow: a business service publishes a JSON payload to a Redis channel. All WebSocket workers subscribed to that channel receive the message, look up the target user's active socket IDs in a Redis hash, and push the payload over the corresponding WebSocket connections. If the user has multiple devices, each device receives the notification independently.

Failure handling: workers heartbeat to Redis (e.g., a worker:heartbeat key with TTL). A separate monitor can detect dead workers and trigger rebalancing. Redis replication and Sentinel/Cluster provide high availability for the broker layer.

Step‑by‑Step Guide

1. Provision Redis

Deploy a Redis instance (or cluster) with persistence disabled for pure Pub/Sub workloads, or enable AOF if you need durability for recent‑message caches. Ensure the maxmemory-policy is set to allkeys-lru to evict stale presence data.

2. Scaffold the Node.js Project

mkdir realtime-notifications && cd realtime-notificationsnpm init -ynpm i express socket.io ioredis dotenv pino pino-prettynpm i -D typescript ts-node @types/node @types/express @types/socket.io

3. Configure TypeScript

{  "compilerOptions": {    "target": "ES2022",    "module": "commonjs",    "outDir": "./dist",    "rootDir": "./src",    "strict": true,    "esModuleInterop": true,    "skipLibCheck": true,    "forceConsistentCasingInFileNames": true  },  "include": ["src/**/*"]}

4. Implement the Redis Client Wrapper

// src/redis.tsimport Redis from 'ioredis';export const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {  maxRetriesPerRequest: 3,  retryStrategy(times) {    return Math.min(times * 50, 2000);  },});redis.on('error', (err) => console.error('Redis error', err));

5. Build the WebSocket Server with Socket.IO

// src/server.tsimport express from 'express';import { createServer } from 'http';import { Server, Socket } from 'socket.io';import { redis } from './redis';import pino from 'pino';const logger = pino({ transport: { target: 'pino-pretty' } });const app = express();const httpServer = createServer(app);const io = new Server(httpServer, {  cors: { origin: process.env.CORS_ORIGIN || '*', methods: ['GET', 'POST'] },  pingInterval: 20000,  pingTimeout: 5000,});// Map userId -> Set stored in Redis hash "user:sockets:{userId}"async function addSocket(userId: string, socketId: string) {  await redis.sadd(`user:sockets:${userId}`, socketId);}async function removeSocket(userId: string, socketId: string) {  await redis.srem(`user:sockets:${userId}`, socketId);}async function getSockets(userId: string): Promise {  return redis.smembers(`user:sockets:${userId}`);}io.on('connection', (socket: Socket) => {  const userId = socket.handshake.auth.userId as string;  if (!userId) {    socket.disconnect(true);    return;  }  addSocket(userId, socket.id);  logger.info({ userId, socketId: socket.id }, 'Socket connected');  socket.on('disconnect', async () => {    await removeSocket(userId, socket.id);    logger.info({ userId, socketId: socket.id }, 'Socket disconnected');  });});// Subscribe to Redis channels for each userconst subscriber = redis.duplicate();subscriber.on('message', async (channel, message) => {  const match = channel.match(/^notifications:user:(.+)$/);  if (!match) return;  const userId = match[1];  const sockets = await getSockets(userId);  sockets.forEach((sid) => io.to(sid).emit('notification', JSON.parse(message)));});async function start() {  await subscriber.subscribe('notifications:user:*', (err) => {    if (err) logger.error(err, 'Failed to subscribe');  });  const port = Number(process.env.PORT) || 3000;  httpServer.listen(port, () => logger.info({ port }, 'Notification server listening'));}start().catch((e) => logger.fatal(e, 'Startup failed'));

6. Publish Notifications from Business Logic

// src/publish.tsimport { redis } from './redis';export async function notifyUser(userId: string, payload: object) {  const channel = `notifications:user:${userId}`;  await redis.publish(channel, JSON.stringify({ ...payload, timestamp: Date.now() }));}

7. Run and Test

# Terminal 1 – start Redis (Docker)docker run -d -p 6379:6379 redis:7-alpine# Terminal 2 – compile & run servernpm run build && node dist/server.js# Terminal 3 – simulate a business eventnode -e "require('./dist/publish').notifyUser('42', { type: 'alert', body: 'Hello!' })"

Real‑World Examples

Chat Application

In a chat app, each conversation maps to a Redis channel chat:room:{roomId}. When a user sends a message, the API publishes to that channel. All participants' WebSocket workers receive the message and push it to the connected clients. Presence (online/offline) is tracked via Redis sets keyed by presence:room:{roomId}.

Live Dashboard for E‑Commerce

An admin dashboard displays real‑time order metrics. The order service publishes events to metrics:orders. A dedicated worker aggregates the stream in memory (or via Redis Streams) and broadcasts a condensed snapshot every second to subscribed admin sockets.

IoT Device Alerts

Devices send telemetry to an MQTT broker. A bridge service converts critical alerts into Redis publications on alerts:device:{deviceId}. The notification workers forward these alerts to the device owner's mobile app via WebSocket, ensuring sub‑second delivery.

Production Code Examples

Graceful Shutdown

// src/shutdown.tsimport { redis, subscriber } from './redis';import { Server } from 'socket.io';import { createServer } from 'http';let io: Server;let httpServer: ReturnType;export function setRefs(i: Server, s: ReturnType) {  io = i;  httpServer = s;}export async function gracefulShutdown(signal: string) {  console.log(`Received ${signal}, closing connections...`);  // Stop accepting new connections  httpServer.close(() => console.log('HTTP server closed'));  // Close all sockets  io.close(() => console.log('Socket.IO server closed'));  // Unsubscribe and quit Redis  await subscriber.unsubscribe('notifications:user:*');  await subscriber.quit();  await redis.quit();  process.exit(0);}process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));process.on('SIGINT', () => gracefulShutdown('SIGINT'));

Rate Limiting per User

// src/rateLimiter.tsimport { redis } from './redis';export async function allowNotification(userId: string, limit = 100, windowMs = 60000): Promise {  const key = `ratelimit:notify:${userId}`;  const current = await redis.incr(key);  if (current === 1) {    await redis.pexpire(key, windowMs);  }  return current <= limit;}

Message Deduplication with Redis Sets

// src/dedupe.tsimport { redis } from './redis';export async function isDuplicate(messageId: string, ttlMs = 86400000): Promise {  const key = `dedupe:msg:${messageId}`;  const added = await redis.set(key, '1', 'PX', ttlMs, 'NX');  return added === null; // null means key already existed}

Comparison Table

FeatureRedis Pub/SubRedis StreamsKafkaRabbitMQ
Latency (typical)< 1 ms< 2 ms2–5 ms1–3 ms
PersistenceNone (fire‑and‑forget)Configurable retentionDisk‑backed, replayableMessage store with ack
ScalabilityHorizontal via clusteringHorizontal via consumer groupsPartitioned topicsClustering & federation
ComplexityVery lowLowHighMedium
Best Fit for NotificationsHigh‑volume, ephemeral alertsNeed replay / orderingEvent sourcing, audit logsComplex routing, DLQ

Best Practices

  • Keep WebSocket workers stateless – store all mapping data in Redis; avoid in‑process maps.
  • Use unique message IDs – enables idempotent processing and deduplication.
  • Implement back‑pressure – if a client's send buffer exceeds a threshold, pause reading from Redis for that socket.
  • Separate control channels – use dedicated Redis channels for presence, heartbeats, and admin commands.
  • Monitor connection metrics – expose Prometheus counters for active sockets, messages sent/received, and error rates.
  • Version your notification payload schema – include a v field; consumers can handle multiple versions gracefully.
  • Automate TLS termination – let the load balancer handle certificates; workers speak plain WebSocket internally.

Common Mistakes

  1. Storing socket references in process memory – leads to lost connections on worker restart or scale‑out.
  2. Publishing large payloads (> 1 MB) over Pub/Sub – Redis Pub/Sub is not designed for big messages; use a reference to object storage instead.
  3. Ignoring client reconnection logic – without exponential back‑off, a fleet of reconnecting clients can cause a thundering herd.
  4. Not handling Redis failover – workers must re‑subscribe automatically when a new master is elected.
  5. Skipping authentication on the WebSocket handshake – exposes the system to unauthorized listeners.

Performance Tips

  • Batch outbound writes – accumulate messages per event loop tick and flush with socket.writev (Node 18+).
  • Enable TCP_NODELAY – reduces latency for small packets; set socket.setNoDelay(true) on the underlying net socket.
  • Use Redis pipelining – when publishing to many user channels at once, pipeline the PUBLISH commands.
  • Compress payloads – apply msgpack or protobuf for high‑frequency updates; negotiate compression during handshake.
  • Shard Redis channels – instead of a single notifications:user:* pattern, use notifications:shard:{hash(userId)} to limit subscriber fan‑out per worker.

Security Considerations

  • Authenticate every WebSocket upgrade – validate a JWT or signed cookie in socket.handshake.auth before calling addSocket.
  • Authorize channel subscriptions – a user may only subscribe to notifications:user:{ownId}; enforce via middleware.
  • Rate‑limit publish endpoints – prevent a compromised service from flooding the broker.
  • Sanitize payloads – strip any fields not defined in the schema to avoid injection attacks.
  • Encrypt data at rest – enable Redis TLS and disk encryption if you persist recent messages.
  • Audit logs – log every publish and subscribe event with user ID, timestamp, and channel for forensic analysis.

Deployment Notes

Container Image

# DockerfileFROM node:20-alpine AS builderWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run buildFROM node:20-alpineWORKDIR /appCOPY --from=builder /app/dist ./distCOPY --from=builder /app/node_modules ./node_modulesENV NODE_ENV=productionEXPOSE 3000CMD ["node", "dist/server.js"]

Kubernetes Deployment

apiVersion: apps/v1kind: Deploymentmetadata:  name: notification-workerspec:  replicas: 3  selector:    matchLabels:      app: notification-worker  template:    metadata:      labels:        app: notification-worker    spec:      containers:      - name: worker        image: your-registry/notification-worker:latest        ports:        - containerPort: 3000        env:        - name: REDIS_URL          value: redis://redis-cluster:6379        - name: CORS_ORIGIN          value: "https://app.example.com"        readinessProbe:          httpGet:            path: /healthz            port: 3000          initialDelaySeconds: 5        livenessProbe:          httpGet:            path: /healthz            port: 3000          initialDelaySeconds: 15

Health Endpoint

// src/health.tsimport { Request, Response } from 'express';export function healthHandler(_req: Request, res: Response) {  res.json({ status: 'ok', uptime: process.uptime() });}

Debugging Tips

  • Enable Socket.IO debug logging – start the process with DEBUG=socket.io* node dist/server.js to see handshake, packet, and heartbeat details.
  • Trace Redis commands – use MONITOR or the redis-cli --stat to observe publish/subscribe traffic.
  • Correlate logs with request IDs – generate a UUID at the API gateway, pass it through headers, and include it in every log line.
  • Simulate network partitions – tools like tc qdisc or Chaos Mesh can inject latency/packet loss to verify reconnection logic.
  • Load test with k6 or wrk2 – script a scenario that opens 10 k concurrent WebSocket connections and publishes 5 k messages/sec; watch CPU, memory, and Redis latency.

FAQ

How do I handle users with multiple browser tabs?

Each tab opens its own WebSocket connection. The Redis set user:sockets:{userId} stores all socket IDs, so a published notification is fanned out to every tab automatically.

Can I use this architecture with Server‑Sent Events (SSE) instead of WebSockets?

Yes. Replace the Socket.IO worker with an Express endpoint that streams text/event-stream. The Redis subscription logic remains identical; you only change the transport layer.

What happens if Redis goes down temporarily?

Workers will lose their Pub/Sub subscription. Implement a reconnection loop in the Redis client (ioredis does this automatically) and re‑subscribe on ready events. Clients will experience a brief delivery gap until the worker resubscribes.

Is it safe to store session data in the same Redis instance used for Pub/Sub?

It works for low‑to‑moderate loads, but for high‑traffic systems isolate Pub/Sub on a dedicated Redis cluster to avoid contention with session reads/writes.

How do I guarantee message ordering per user?

Redis Pub/Sub delivers messages in publish order per channel. Since each user has a dedicated channel notifications:user:{id}, ordering is guaranteed for that user.

Can I integrate with a CDN for fallback delivery?

For users behind restrictive firewalls, you can fall back to long‑polling or push via a push service (e.g., Firebase Cloud Messaging). The notification worker can detect failed WebSocket sends and enqueue a push job.

What monitoring metrics are most valuable?

Active connections, messages published per second, messages delivered per second, Redis command latency (p50/p99), worker CPU/memory, and error rates (failed sends, auth failures).

How do I test the system locally without a full Kubernetes cluster?

Use Docker Compose to spin up Redis, the notification worker, and a simple front‑end HTML page that opens a WebSocket. The compose file can also include a mock publisher service.

Conclusion

Building a scalable real‑time notification system is achievable with a modest stack: Node.js for the WebSocket layer, Redis for lightning‑fast Pub/Sub, and a little discipline around stateless design. By following the architecture, code patterns, and operational guidance outlined here, you can deliver millions of notifications per second while keeping latency under 100 ms and maintaining high availability.

Ready to ship real‑time experiences? Clone the starter repository, adapt the payload schema to your domain, and deploy the worker fleet behind your load balancer. Your users will thank you for the instant feedback loop.

Next step: Explore adding message persistence with Redis Streams for replayability, or integrate a push‑notification gateway for mobile offline delivery.