惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

aimingoo的专栏
aimingoo的专栏
V
V2EX
G
Google Developers Blog
F
Full Disclosure
Martin Fowler
Martin Fowler
宝玉的分享
宝玉的分享
H
Hacker News: Front Page
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
NISL@THU
NISL@THU
G
GRAHAM CLULEY
V
Vulnerabilities – Threatpost
Hacker News - Newest:
Hacker News - Newest: "LLM"
A
About on SuperTechFans
The Cloudflare Blog
C
Cisco Blogs
D
DataBreaches.Net
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Vercel News
Vercel News
P
Privacy International News Feed
Microsoft Security Blog
Microsoft Security Blog
Help Net Security
Help Net Security
Recorded Future
Recorded Future
PCI Perspectives
PCI Perspectives
S
Schneier on Security
AI
AI
N
News | PayPal Newsroom
雷峰网
雷峰网
C
Cyber Attacks, Cyber Crime and Cyber Security
P
Proofpoint News Feed
The Last Watchdog
The Last Watchdog
L
LINUX DO - 最新话题
Hugging Face - Blog
Hugging Face - Blog
Apple Machine Learning Research
Apple Machine Learning Research
Schneier on Security
Schneier on Security
S
Securelist
云风的 BLOG
云风的 BLOG
Stack Overflow Blog
Stack Overflow Blog
博客园_首页
AWS News Blog
AWS News Blog
TaoSecurity Blog
TaoSecurity Blog
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Recent Commits to openclaw:main
Recent Commits to openclaw:main
博客园 - 三生石上(FineUI控件)
C
CXSECURITY Database RSS Feed - CXSecurity.com
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
Cloudbric
Cloudbric
C
Cybersecurity and Infrastructure Security Agency CISA
Project Zero
Project Zero
C
Check Point Blog
S
Security Affairs

The Practical Developer

The Libuv Thread Pool Trap: Why Node.js Async APIs Stall Under Load Postgres Covering Indexes with INCLUDE: Eliminate Heap Fetches on Read-Heavy Workloads Postgres DISTINCT ON: The Fastest Way to Get the Latest Row Per Group Postgres Transaction Isolation: The Anomalies Your App Actually Faces in Production Linux TCP Tuning for Node.js Microservices: The Kernel Settings That Stop Silent Connection Drops Under Load Postgres HOT Updates and Fillfactor: Why Not All Writes Are Created Equal Database Connection Pool Leaks: Finding the Promise That Never Returns Its Seat Linux OOM Killer in Production: Why Your Node.js Containers Die Without a Stack Trace Postgres Materialized Views: Refresh Strategies That Do Not Lock Your Dashboards API Dependency Health Checks: Why /health Is Not Enough Authorization with Zanzibar Tuples: How Google Manages Permissions and How To Build the Same Check in Node.js Postgres Advisory Locks: The 20-Character Primitive That Replaces Redis for Coordination Dead Letter Queues: The Message Queue Pattern That Saves You at 2 a.m. File Descriptor Exhaustion: The Kernel Limit That Silently Drops Node.js Connections Graceful Degradation: The Pattern That Turns Total Outages into Partial Success PostgreSQL Full-Text Search: Dropping Elasticsearch for 90% of Use Cases S3 Presigned Multipart Uploads: Stop Your API Server from Being a File Upload Bottleneck MessagePack vs JSON: The Binary Serialization Switch That Cut Our Internal RPC Overhead by 40% DNS Caching in Node.js: The Silent Cause of Production Latency Spikes Reliable Cron Jobs: The Pattern That Stops Double Runs, Missed Executions, And The 2 AM Page GraphQL Query Complexity: Stop the OOM Query Before It Reaches Your Resolver Node.js Event Loop Lag: The Hidden Metric Behind Random Latency Spikes API Request Validation with Zod: The Schema That Catches Bad Input Before It Corrupts Your Database Load Shedding in Node.js: How to Reject Traffic Before You Drown Request Hedging: Cut Tail Latency In Half Without Overprovisioning Git Bisect: The Automated Binary Search That Finds Breaking Commits in Minutes Node.js Garbage Collection Tuning: Stop Letting V8 Pause Your Event Loop Node.js Server Timeouts: The Settings That Stop Slow Clients from Holding Sockets Hostage Postgres BRIN Indexes: The Time-Series Secret That Shrinks Indexes by 99% Event Sourcing with PostgreSQL: The Pragmatic 80% Solution Node.js Cluster Mode: Scaling the Event Loop Across CPU Cores Postgres Partial Indexes: Stopping Soft Deletes from Ruining Your Query Performance Request Coalescing with the Singleflight Pattern: Stop Drowning Your Database on Every Cache Miss The Bulkhead Pattern: Why One Slow Endpoint Should Not Drown Your Whole Service Node.js AsyncLocalStorage: End-to-End Request Context Without the Propagation Hell Postgres Deadlocks: Logging the Victim, Reproducing the Race, and Fixing the Lock Order Your Node.js HTTP Client Is the Bottleneck: Connection Pool Tuning That Works Optimistic Locking in Postgres: Stop Losing Data to Race Conditions Postgres Read Replicas: Stop Serving Stale Data to Your Users Cursor Pagination: Why Offset Queries Explode at Scale and How to Fix Them Node.js Worker Threads: 60 Lines That Stop a CSV Upload from Timing Out Every Other Request Reliable Webhook Delivery: Architecture for Outbound HTTP You Can Trust Request Timeouts and Deadline Propagation: Stop the Chain of Slowness Advanced Security Practices in Node.js Graceful Shutdown in Node.js: The 40 Lines That Stop 502s During Deploys Finding Node.js Memory Leaks with Heap Snapshots Idempotency Keys in 30 Lines: Stop Your Webhook From Charging Customers Twice Backpressure In Node.js: The Fix For Slow-Motion Queue Meltdowns Retries Done Right: Jitter, Budgets, and the Stampede You Did Not See Coming The Cache Stampede: Why Your "Just Add Redis" Layer Crashes Postgres at 3 a.m. Postgres SKIP LOCKED: An 80-Line Job Queue You Can Run Without Redis Stop Doing Work Nobody Wants: AbortController in Node.js, Done Right The N+1 Query Problem: We Found 23 In One Codebase And Killed Every One I Tried 5 AI Coding Tools for a Month. Here Is What I Actually Use CI/CD From Zero to Production in 30 Minutes With GitHub Actions Node.js vs Bun vs Deno: Which Runtime Should You Pick in 2025? Kubernetes Resource Requests And Limits: The Numbers That Decide If Your Cluster Is Stable The Three Pillars of Observability Are A Myth: What Actually Matters In Production pnpm Vs npm Vs yarn Vs Bun For Monorepos: Which One Earns The Migration In 2024 JSONB Indexing In Postgres: GIN Vs Expression Indexes, And When Each Is The Right Choice A Code Review Checklist That Ends The Same Three Arguments Every Sprint gRPC Vs REST In 2024: When The Switch Pays For Itself React Suspense For Data Fetching: The Pattern That Replaces Half Your Loading State Code The Five-Stage Rollout: How To Ship A Risky Change Without Holding Your Breath GitHub Actions In A Monorepo: Caching, Path Filters, And Secret Boundaries That Actually Work The Blameless Postmortem That Actually Improves Things: A Template And Six Hard-Won Rules Recursive CTEs In Postgres: How To Query A Tree Without N Round Trips Node.js Streams: When They Actually Help, And When They Just Add Complexity Playwright Vs Cypress In 2024: The Honest Comparison Of Which One Earns The Test Time React Server Components: The Mental Model That Makes The "use client" Boundary Obvious Pod Disruption Budgets: The K8s Object That Keeps Your Service Up During Cluster Maintenance Postgres LISTEN/NOTIFY: The Pub/Sub You Already Have And Are Not Using Chaos Engineering Starter Kit: The Five Drills That Don't Need Netflix-Scale Spec-Driven API Development With OpenAPI: How To Stop Drifting From Your Docs Kubernetes Autoscaling Beyond CPU: The Custom-Metric HPA Pattern That Actually Works Postgres Partitioning For Time-Series: The Boring Setup That Saves Your Database Distributed Locks With Redis: An Honest Look At Redlock And When You Don't Need It HTTP/2 vs HTTP/3: What Actually Changes For Your App, And What Doesn't Image Optimization For The Web In 2023: srcset, AVIF, And The Lighthouse Score You Actually Want Kafka vs RabbitMQ: A Decision Tree That Doesn't Hate You UUID vs Bigint Primary Keys In Postgres: The Index Math That Decides For You Flame Graphs: How To Find The Slow Function In 30 Seconds Without Profiling Theatre Postgres Streaming Vs. Logical Replication: Which One Solves Your Actual Problem ESLint Rules That Earn Their Keep: The Twelve I Enable On Every Project Pre-Commit Hooks That Pay For Themselves: Husky, lint-staged, And The Five Rules That Stick Zero-Downtime Database Migrations: The Six-Step Pattern That Rules Them All Circuit Breakers In Node.js: 50 Lines That Stop A Failing Dependency From Taking Down Your Service Postgres VACUUM Is Not Magic: How Your Hot Table Bloats To 80GB And How To Fix It Kubernetes Liveness And Readiness Probes: The Difference That Causes Half Your Outages Rate Limiting In Production: A Token Bucket In 30 Lines Of Redis The Outbox Pattern: How To Stop Losing Events When Postgres And Kafka Disagree Load Testing With k6: The Three Scenarios That Find Real Bugs (Not Synthetic Numbers) Postgres Row-Level Security For Multi-Tenant Apps: The Pattern That Stops You From Leaking Data Rebase vs. Merge: The Team Policy That Ends The Argument Forever OpenTelemetry in Node.js: Distributed Tracing That Actually Helps During an Incident Feature Flags That Pay Rent: The 4 Flag Types And When To Delete Each ETag, Last-Modified, and the Caching Headers Most APIs Get Wrong Connection Pooling Without the Cargo Cult: pgbouncer in 100 Lines of Config JSONB Is Not a Schema: When To Reach For It in Postgres, And When To Stop Bash Strict Mode: The Three Lines That Stop Your Deploy Script From Lying To You
Scaling WebSockets Horizontally with Redis: From One Server to a Cluster of Real-Time Connections
The Practica · 2026-06-23 · via The Practical Developer

Your WebSocket server works fine on your laptop. One instance, a dozen connections, every message arrives where it should. Then you deploy to production. You add a second instance behind a load balancer. And suddenly messages disappear. User A, connected to instance 1, sends a message to User B, connected to instance 2, and the message never arrives. User A’s client thinks it sent. User B sees nothing. The user opens a bug ticket. You add logging. You see the message arrive at instance 1. It broadcasts to all connections on instance 1. Instance 2 never gets the memo.

This is the WebSocket scaling wall. And if you hit it without a plan, you will spend a week introducing a message bus, and during that week your users will keep filing bugs.

Here is the plan.

The Problem: WebSocket Connections Are Tied to a Process

A regular HTTP request is stateless. You can route it to any instance in your cluster and get the same result. A WebSocket connection, once established, lives on a single process. The TCP socket is pinned to that Node.js process, that container, that machine. When user A connects to instance 1, their socket lives in instance 1’s memory. Any broadcast or targeted message that originates on instance 2 has no way to reach that socket unless you build a bridge.

The naive fix is sticky sessions. Configure your load balancer to pin a client to the same instance based on a cookie or IP hash. It works for two instances. It breaks when an instance restarts, when the load balancer rebalances, or when you scale up during a traffic spike. And it does nothing to help a message that originates from a different service entirely (a background job, a webhook handler, another WebSocket server).

You need an external message bus. Redis is the simplest one that works at production scale without a dedicated ops team.

The Architecture: Redis as the Cross-Instance Bridge

Every WebSocket server instance connects to the same Redis instance (or cluster). When a message needs to reach a user who is not connected to the current server, the server publishes that message to a Redis channel. Every other server instance subscribes to that same channel, receives the message, and forwards it to the relevant local socket.

User A --WS--> Instance 1 --PUB--> Redis --SUB--> Instance 2 --WS--> User B

This is the WebSocket equivalent of a message queue: publish once, deliver to every subscriber, and let each subscriber decide whether the message belongs to a local connection.

Step 1: Track Connections Per Instance

Before you can route messages, you need to know which user is connected to which server instance. Each instance maintains a local map of authenticated user IDs to WebSocket connections. When a user connects and authenticates, push their ID into the map. When they disconnect, remove it.

import { WebSocketServer, WebSocket } from 'ws';
import { createClient } from 'redis';

const wss = new WebSocketServer({ port: 8080 });

// Local connection map: userId -> Set<WebSocket>
// One user can have multiple tabs/device connections
const connections = new Map<string, Set<WebSocket>>();

// Subscribe to Redis for cross-instance messages
const subscriber = createClient({ url: process.env.REDIS_URL });
await subscriber.connect();

await subscriber.subscribe('ws:messages', (rawMessage) => {
  const { targetUserId, payload } = JSON.parse(rawMessage);
  const userSockets = connections.get(targetUserId);
  if (!userSockets) return; // This user is not on this instance

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

The connections map is the source of truth for local sockets. It lives in process memory, so lookups are O(1) and there is no Redis round trip for every incoming message. The Redis subscription only handles messages that need to cross instance boundaries.

Step 2: Publish to Redis When the Target Is Remote

When a user sends a message, you need to decide: is the target on this instance or on another instance? You cannot know without a global registry. The simplest approach is to always publish to Redis and let each instance filter locally. The overhead of an empty filter is a JSON parse and a map lookup, which is negligible at the message rates most applications handle.

wss.on('connection', (ws, req) => {
  // Authenticate (simplified for clarity)
  const userId = await authenticate(req);
  registerConnection(userId, ws);

  ws.on('message', (rawData) => {
    const message = JSON.parse(rawData.toString());

    // Publish to Redis channel
    // Every instance receives this, including the sender
    const redisMessage = JSON.stringify({
      targetUserId: message.recipientId,
      payload: { from: userId, text: message.text, timestamp: Date.now() },
    });
    await publisher.publish('ws:messages', redisMessage);
  });

  ws.on('close', () => {
    removeConnection(userId, ws);
  });
});

You need a separate Redis client for publishing because Redis does not allow a client to publish while in subscriber mode. Create two clients:

const publisher = createClient({ url: process.env.REDIS_URL });
const subscriber = createClient({ url: process.env.REDIS_URL });
await Promise.all([publisher.connect(), subscriber.connect()]);

One client publishes. The other subscribes. Never mix the two.

Step 3: Avoid Echoes with a Sender Filter

If every instance publishes to the same Redis channel and every instance subscribes to it, the sending instance receives its own message back. The subscriber handler picks it up, finds the target user in the local connections map, and sends the WebSocket message again. The client gets a duplicate.

Fix this with a sender filter. Tag every message with the originating instance ID, and skip delivery in the subscriber handler if the message originated from the current instance AND the socket belongs to the sender.

const INSTANCE_ID = crypto.randomUUID();

await subscriber.subscribe('ws:messages', (rawMessage) => {
  const { targetUserId, payload, originInstanceId, senderUserId } = JSON.parse(rawMessage);

  // Skip echo: if this message came from our instance and the
  // recipient is the same user who sent it, they already got it.
  if (originInstanceId === INSTANCE_ID && targetUserId === senderUserId) {
    return;
  }

  const userSockets = connections.get(targetUserId);
  if (!userSockets) return;

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

// When publishing:
await publisher.publish('ws:messages', JSON.stringify({
  targetUserId: message.recipientId,
  senderUserId: userId,
  originInstanceId: INSTANCE_ID,
  payload: { from: userId, text: message.text, timestamp: Date.now() },
}));

This eliminates duplicates without adding a Redis SISMEMBER check or a distributed deduplication set. The sender’s message reaches them once via the local handler and is suppressed in the subscriber handler.

Step 4: Handle Disconnections

When a user disconnects, remove them from the local map. But what about messages that arrive after the disconnect but before the subscriber processes the removal? That is fine. The subscriber calls connections.get(targetUserId), gets undefined, and drops the message. The user’s client has already closed the socket.

The harder problem is a server crash. If instance 1 crashes, any messages published by instance 2 to ws:messages will never reach instance 1’s users. And instance 1’s users are now disconnected from the chat entirely. That is acceptable for a real-time chat app as long as the clients reconnect and the new connection gets assigned to a live instance. The reconnection logic (exponential backoff with jitter) is the client’s responsibility.

But you can do better. Store the user-to-instance mapping in Redis itself, so when a user reconnects to instance 3, instance 3 can recover the session state and rejoin the user to any active rooms or channels.

// On authentication, store the mapping in Redis with a TTL
await publisher.set(`ws:user:${userId}`, INSTANCE_ID, {
  EX: 60, // Expire after 60 seconds
});

// Refresh the TTL periodically with a heartbeat
setInterval(async () => {
  for (const userId of connections.keys()) {
    await publisher.set(`ws:user:${userId}`, INSTANCE_ID, { EX: 60 });
  }
}, 30_000);

// When publishing, check if the target is on a different instance
async function sendToUser(userId: string, payload: unknown) {
  const targetInstance = await publisher.get(`ws:user:${userId}`);

  if (targetInstance === INSTANCE_ID) {
    // Local delivery
    const sockets = connections.get(userId);
    if (!sockets) return;
    for (const ws of sockets) {
      if (ws.readyState === WebSocket.OPEN) {
        ws.send(JSON.stringify(payload));
      }
    }
    return;
  }

  // Remote delivery via Redis channel
  await publisher.publish('ws:messages', JSON.stringify({
    targetUserId: userId,
    originInstanceId: INSTANCE_ID,
    payload,
  }));
}

Now you have a global routing table. When a user switches instances (their original server crashed and they reconnected to instance 3), the Redis key updates within 60 seconds (or 30 seconds if the heartbeat fires). Messages arriving in the gap are published to the old channel and lost. The TTL ensures the stale mapping cleans itself up.

Step 5: Room-Based Multicast

If your application uses rooms, channels, or groups (most real-time apps do), the pattern extends naturally. Instead of targeting a single user, publish to a room channel. Each instance subscribes to the rooms that have local members.

// When a user joins a room
async function joinRoom(userId: string, roomId: string, ws: WebSocket) {
  // Track locally
  if (!rooms.has(roomId)) rooms.set(roomId, new Map());
  const members = rooms.get(roomId)!;
  if (!members.has(userId)) members.set(userId, new Set());
  members.get(userId)!.add(ws);

  // Subscribe to the room channel on Redis if not already subscribed
  const roomChannel = `ws:room:${roomId}`;
  if (!subscribedRooms.has(roomChannel)) {
    await subscriber.subscribe(roomChannel, (rawMessage) => {
      const { payload, excludeUserId, originInstanceId } = JSON.parse(rawMessage);

      // Skip echo
      if (originInstanceId === INSTANCE_ID) return;

      const roomMembers = rooms.get(roomId);
      if (!roomMembers) return;

      for (const [memberId, sockets] of roomMembers) {
        if (memberId === excludeUserId) continue;
        for (const sock of sockets) {
          if (sock.readyState === WebSocket.OPEN) {
            sock.send(JSON.stringify(payload));
          }
        }
      }
    });
    subscribedRooms.add(roomChannel);
  }
}

// When sending a message to a room
async function sendToRoom(roomId: string, payload: unknown, excludeUserId?: string) {
  // Publish to the room channel
  await publisher.publish(`ws:room:${roomId}`, JSON.stringify({
    payload,
    excludeUserId,
    originInstanceId: INSTANCE_ID,
  }));

  // Also deliver locally (the Redis subscriber skips originInstanceId === INSTANCE_ID)
  const roomMembers = rooms.get(roomId);
  if (!roomMembers) return;
  for (const [memberId, sockets] of roomMembers) {
    if (memberId === excludeUserId) continue;
    for (const sock of sockets) {
      if (sock.readyState === WebSocket.OPEN) {
        sock.send(JSON.stringify(payload));
      }
    }
  }
}

This avoids broadcasting every room message to every instance. Instances only subscribe to rooms that have local members. If room “general” has 10,000 members spread across 5 instances, every instance receives the message, and 10,000 clients get it. That is the unavoidable broadcast cost. But if room “private-team-alpha” has 3 members all on instance 2, instance 1 does not even see the message. The per-room subscription granularity saves Redis bandwidth and CPU.

Step 6: Graceful Shutdown and Connection Draining

When you deploy a new version, Kubernetes or your orchestrator sends a SIGTERM to the old pods. Your WebSocket server has open connections. If you exit immediately, every connected user drops with a connection reset. They reconnect, hit the load balancer, and establish new sockets on the new instances. That works, but the reconnect storm can spike your database connection pool and cause cascading failures.

Drain connections gracefully. Close the HTTP server to stop accepting new connections, notify connected clients that a reconnect is coming, and then close each socket with a normal closure code.

process.on('SIGTERM', async () => {
  console.log(`[${INSTANCE_ID}] Shutting down, draining ${connections.size} users`);

  // Stop accepting new connections
  wss.close();

  // Notify all connected clients to reconnect
  const reconnectPayload = JSON.stringify({
    type: 'server:reconnect',
    reconnectAfter: 1000,
    server: INSTANCE_ID,
  });

  for (const sockets of connections.values()) {
    for (const ws of sockets) {
      try {
        ws.send(reconnectPayload);
        ws.close(4001, 'Server restarting');
      } catch {
        // Socket already closed
      }
    }
  }

  // Wait for the client to receive the message
  await new Promise((resolve) => setTimeout(resolve, 2000));

  // Unsubscribe from Redis
  await subscriber.unsubscribe('ws:messages');
  await subscriber.quit();
  await publisher.quit();

  process.exit(0);
});

The client should recognize the server:reconnect message type, close the current socket, and initiate a new connection with exponential backoff. This turns a chaotic disconnect storm into an orderly reconnection wave.

Putting It All Together

Here is the complete server skeleton with all the pieces:

import { WebSocketServer, WebSocket } from 'ws';
import { createClient } from 'redis';

const INSTANCE_ID = crypto.randomUUID();
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';

const connections = new Map<string, Set<WebSocket>>();
const rooms = new Map<string, Map<string, Set<WebSocket>>>();
const subscribedRooms = new Set<string>();

const publisher = createClient({ url: REDIS_URL });
const subscriber = createClient({ url: REDIS_URL });

await Promise.all([publisher.connect(), subscriber.connect()]);

// Global message channel
await subscriber.subscribe('ws:messages', (raw) => {
  const { targetUserId, payload, originInstanceId, senderUserId } = JSON.parse(raw);
  if (originInstanceId === INSTANCE_ID && targetUserId === senderUserId) return;

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

const wss = new WebSocketServer({ port: 8080 });

wss.on('connection', async (ws, req) => {
  const userId = await authenticate(req); // Your auth logic
  registerConnection(userId, ws);

  ws.on('message', async (raw) => {
    const msg = JSON.parse(raw.toString());

    if (msg.type === 'direct') {
      await publisher.publish('ws:messages', JSON.stringify({
        targetUserId: msg.recipientId,
        senderUserId: userId,
        originInstanceId: INSTANCE_ID,
        payload: msg.data,
      }));
    } else if (msg.type === 'room') {
      await publisher.publish(`ws:room:${msg.roomId}`, JSON.stringify({
        payload: msg.data,
        originInstanceId: INSTANCE_ID,
      }));
      // Local delivery handled in the subscriber
    }
  });

  ws.on('close', () => removeConnection(userId, ws));
});

function registerConnection(userId: string, ws: WebSocket) {
  if (!connections.has(userId)) connections.set(userId, new Set());
  connections.get(userId)!.add(ws);

  // Heartbeat
  publisher.set(`ws:user:${userId}`, INSTANCE_ID, { EX: 60 });
}

function removeConnection(userId: string, ws: WebSocket) {
  const sockets = connections.get(userId);
  if (!sockets) return;
  sockets.delete(ws);
  if (sockets.size === 0) connections.delete(userId);
}

// Heartbeat interval
setInterval(() => {
  for (const userId of connections.keys()) {
    publisher.set(`ws:user:${userId}`, INSTANCE_ID, { EX: 60 });
  }
}, 30_000);

What This Pattern Does Not Do

This pattern handles real-time messaging across N instances. It does not handle:

  • Persistent message history. Redis Pub/Sub has no built-in message persistence. If a subscriber is disconnected briefly, it misses messages. Use Redis Streams if you need guaranteed delivery, or write messages to Postgres before publishing.
  • Exactly-once delivery. The subscriber callback fires once per connected Redis client. If the callback throws, the message is lost. Wrap the handler in a try/catch and log failures.
  • Ordering across rooms. Messages on different channels are processed concurrently. If your application requires strict ordering across all messages, use a single channel and serialize through it. This limits throughput, so only do this if you actually need it.

The Practical Takeaway

If you have more than one WebSocket server instance, you need a cross-instance message bus. Redis Pub/Sub is the simplest option that scales to thousands of connections across dozens of instances. Create two Redis clients (publisher and subscriber), tag messages with an origin instance ID to suppress echoes, and subscribe per-room to avoid broadcasting every message to every server.

Start with the basic global channel. Add per-room subscriptions when you measure Redis CPU as a bottleneck. Add the user-to-instance mapping in Redis when you need faster failover. Do not add sticky sessions as a shortcut — they mask the problem and introduce a new one when an instance dies.

Ship the client reconnection logic before you ship the server. Without it, scaling WebSockets just means more users experience the same disconnect delay when you deploy.

A note from Yojji

Building real-time infrastructure that survives traffic spikes, instance failures, and rolling deployments is the kind of systems engineering that separates a prototype from a production service. The patterns in this post (cross-instance messaging, connection draining, room-based subscription filtering) are exactly the type of architectural decisions Yojji’s teams make daily when building scalable backend systems for their clients. Yojji is an international custom software development company founded in 2016, with offices in Europe, the US, and the UK. Their teams specialize in the JavaScript ecosystem (React, Node.js, TypeScript), cloud platforms (AWS, Azure, Google Cloud), and full-cycle product engineering from discovery through DevOps. If your real-time stack is held together by a single instance and a hope that nobody deploys on a Friday, Yojji’s engineering teams have built this pattern at scale and can help ship a production-grade replacement.