All posts
25 Aug 2026

Scaling WebSockets in Node.js: Real-Time Communication Across Clustered Instances with Redis

A practical, code-heavy guide to managing persistent WebSocket connections in Node.js, handling reconnection logic, authenticating handshakes, and synchronizing state across multiple server instances using Redis Pub/Sub.

Scaling WebSockets in Node.js: Real-Time Communication Across Clustered Instances with Redis

Building real-time applications with WebSockets in Node.js is straightforward when dealing with a single server instance. You initialize a WebSocket server, accept connections, store them in an in-memory map, and broadcast messages as needed. However, the moment your application grows and you need to scale horizontally behind a load balancer, that simple in-memory architecture shatters.

Because WebSocket connections are stateful and persistent TCP connections, a client connected to Server A cannot receive a broadcast initiated on Server B unless those servers can communicate. To solve this distributed systems challenge, we rely on Redis Pub/Sub to act as a message bus, synchronizing events across all active Node.js instances.

In this comprehensive guide, we will build a robust, horizontally scalable WebSocket architecture in Node.js from scratch, incorporating secure handshakes, connection lifecycle management, and Redis adapter synchronization.


The Architecture of Distributed WebSockets

Before diving into the code, let’s visualize how multi-node WebSocket routing works:

code
[Client A] <--> [Node.js Instance 1] \
                                      +---> [Redis Pub/Sub Bus]
[Client B] <--> [Node.js Instance 2] /
  1. Client A connects to Node.js Instance 1.
  2. An event occurs that requires broadcasting a message to all connected users.
  3. Node.js Instance 2 receives the trigger and publishes the message to a Redis Channel.
  4. Node.js Instance 1 (along with every other scaled instance) subscribes to that Redis channel, receives the payload, and pushes it down to Client A.

Step 1: Setting Up the Core WebSocket Server

We will use ws, the fastest and most efficient WebSocket library for Node.js, alongside ioredis for our Redis integration.

First, initialize your project and install dependencies:

npm init -y
npm install express ws ioredis dotenv
npm install --save-dev typescript @types/node @types/ws ts-node

Create a basic server file (server.ts) that sets up an HTTP server and attaches the WebSocket server.

import http from 'http';
import express from 'express';
import { WebSocketServer, WebSocket } from 'ws';
import dotenv from 'dotenv';

dotenv.config();

const app = express();
const server = http.createServer(app);
const wss = new WebSocketServer({ noServer: true });

const PORT = process.env.PORT || 3000;

// Track local connections
const localClients = new Set<WebSocket>();

wss.on('connection', (ws: WebSocket, req) => {
  console.log(`[Connection] New client connected from ${req.socket.remoteAddress}`);
  localClients.add(ws);

  ws.on('message', (message: string) => {
    try {
      const parsed = JSON.parse(message);
      handleIncomingMessage(ws, parsed);
    } catch (err) {
      console.error('Invalid JSON received', err);
    }
  });

  ws.on('close', () => {
    console.log('[Connection] Client disconnected');
    localClients.delete(ws);
  });
});

function handleIncomingMessage(ws: WebSocket, data: any) {
  // Echo back or process business logic
  ws.send(JSON.stringify({ status: 'acknowledged', data }));
}

server.listen(PORT, () => {
  console.log(`Server listening on port ${PORT}`);
});

Step 2: Securing the Handshake and Authenticating Connections

Accepting every incoming connection without validation opens your application up to abuse. Because WebSockets start as standard HTTP requests, we can authenticate clients during the HTTP upgrade phase using query parameters or signed tokens (JWT).

Update your server to intercept the upgrade event:

import { parse } from 'url';
import jwt from 'jsonwebtoken';

interface JwtPayload {
  userId: string;
}

server.on('upgrade', (request, socket, head) => {
  const { query } = parse(request.url || '', true);
  const token = query.token as string;

  if (!token) {
    socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
    socket.destroy();
    return;
  }

  try {
    // Verify JWT token
    const decoded = jwt.verify(token, process.env.JWT_SECRET || 'supersecret') as JwtPayload;
    
    wss.handleUpgrade(request, socket, head, (ws) => {
      // Attach user metadata to the WebSocket instance
      (ws as any).userId = decoded.userId;
      wss.emit('connection', ws, request);
    });
  } catch (err) {
    socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
    socket.destroy();
  }
});

Step 3: Implementing Redis Pub/Sub for Horizontal Scaling

To ensure messages propagate across distinct Node.js instances, we must set up a dedicated Redis publisher and subscriber. Redis requires separate client connections for publishing and subscribing.

Create a RedisManager.ts module:

import Redis from 'ioredis';
import { WebSocket } from 'ws';

export class RedisManager {
  private pub: Redis;
  private sub: Redis;
  private channel = 'websocket_broadcast';

  constructor() {
    const redisUrl = process.env.REDIS_URL || 'redis://localhost:6379';
    this.pub = new Redis(redisUrl);
    this.sub = new Redis(redisUrl);
  }

  public async init(localClients: Set<WebSocket>) {
    await this.sub.subscribe(this.channel);
    
    this.sub.on('message', (channel, message) => {
      if (channel === this.channel) {
        const parsedMessage = JSON.parse(message);
        this.broadcastLocally(localClients, parsedMessage);
      }
    });
  }

  public async publish(event: string, payload: any) {
    const message = JSON.stringify({ event, payload });
    await this.pub.publish(this.channel, message);
  }

  private broadcastLocally(localClients: Set<WebSocket>, data: any) {
    const packet = JSON.stringify(data);
    for (const client of localClients) {
      if (client.readyState === WebSocket.OPEN) {
        client.send(packet);
      }
    }
  }

  public async disconnect() {
    await this.pub.quit();
    await this.sub.quit();
  }
}

Step 4: Integrating Redis into the Main Application Loop

Now, wire the RedisManager into your primary server file. When any client sends an event that needs to be distributed across the cluster, we publish it to Redis rather than looping over local sockets directly.

import { RedisManager } from './RedisManager';

const redisManager = new RedisManager();
const localClients = new Set<WebSocket>();

redisManager.init(localClients).then(() => {
  console.log('Redis Pub/Sub adapter initialized.');
});

wss.on('connection', (ws: WebSocket, req) => {
  const userId = (ws as any).userId;
  localClients.add(ws);

  ws.on('message', async (message: string) => {
    try {
      const { action, payload } = JSON.parse(message);
      
      if (action === 'global_broadcast') {
        // Broadcast to all nodes via Redis
        await redisManager.publish('global_message', {
          senderId: userId,
          content: payload,
          timestamp: Date.now()
        });
      }
    } catch (err) {
      console.error('Failed to process message:', err);
    }
  });

  ws.on('close', () => {
    localClients.delete(ws);
  });
});

Step 5: Robust Client-Side Reconnection Strategy

Scaling backend architecture means little if clients cannot gracefully recover from network drops, server deployments, or proxy timeouts. We must implement exponential backoff with jitter on the client side.

Here is a resilient client-side connection wrapper (runnable in modern browsers or Node.js test scripts):

class ResilientWebSocket {
  constructor(url, token) {
    this.url = `${url}?token=${token}`;
    this.reconnectAttempts = 0;
    this.maxReconnectDelay = 30000;
    this.connect();
  }

  connect() {
    console.log('Connecting to WebSocket server...');
    this.ws = new WebSocket(this.url);

    this.ws.onopen = () => {
      console.log('WebSocket connection established.');
      this.reconnectAttempts = 0; // Reset backoff on success
    };

    this.ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      console.log('Received message:', data);
    };

    this.ws.onclose = (event) => {
      console.warn(`WebSocket closed (code: ${event.code}). Attempting reconnection...`);
      this.handleReconnect();
    };

    this.ws.onerror = (error) => {
      console.error('WebSocket error encountered:', error);
      this.ws.close();
    };
  }

  handleReconnect() {
    const delay = Math.min(
      this.maxReconnectDelay,
      Math.pow(2, this.reconnectAttempts) * 1000
    );
    
    // Add random jitter (±25%) to prevent thundering herd problem
    const jitter = delay * 0.25 * (Math.random() * 2 - 1);
    const finalDelay = Math.round(delay + jitter);

    this.reconnectAttempts++;

    setTimeout(() => {
      this.connect();
    }, finalDelay);
  }

  send(action, payload) {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify({ action, payload }));
    } else {
      console.error('Cannot send message: WebSocket is not open.');
    }
  }
}

Best Practices for Production Deployment

  1. Load Balancer Configuration (Sticky Sessions): Ensure your load balancer (such as Nginx, AWS ALB, or Cloudflare) supports sticky sessions (session affinity) if you maintain local state maps, though Redis Pub/Sub reduces the absolute necessity of strict routing.
  2. TCP Keepalive: Configure TCP keepalive probes on your WebSocket server to cleanly drop ghost connections left behind by abrupt client dropouts:
    const server = http.createServer(app);
    server.keepAliveTimeout = 65000;
    
  3. Memory Leaks & Heartbeats: Implement active server-side ping/pong heartbeats to prune dead connections:
    setInterval(() => {
      wss.clients.forEach((ws: any) => {
        if (ws.isAlive === false) return ws.terminate();
        ws.isAlive = false;
        ws.ping();
      });
    }, 30000);
    

Conclusion

Scaling WebSockets in Node.js requires shifting your mental model from single-instance event loops to distributed cluster communication. By combining standard libraries like ws and ioredis, you can build an elastic real-time backend capable of handling millions of concurrent users. Coupled with authenticated handshakes and exponential client-side backoff, your infrastructure will remain resilient under heavy load and intermittent network conditions.

More posts