WebSockets are deceptively easy to get running locally. You spin up a single Node.js process, wire up socket.io, and within twenty minutes you have a live chat demo. Then you deploy to production, scale to three instances behind a load balancer, and half your users stop receiving events. Welcome to the real challenge.
This guide skips the basics and goes straight to the production problem: how do you broadcast WebSocket events reliably when your app runs on multiple server instances?
Why Single-Server WebSocket Logic Breaks at Scale
Each Node.js process maintains its own in-memory map of connected sockets. When a client connects, it lands on exactly one instance — whichever the load balancer routes it to. If a user on Instance A triggers an event that should broadcast to a user on Instance B, Instance A has no knowledge of that second connection. The message is simply never sent.
This is not a bug in your code. It is the expected behavior of a stateful protocol colliding with stateless horizontal scaling.
The canonical fix is to introduce a shared message bus that every instance both publishes to and subscribes from. Redis Pub/Sub is the industry standard for this because it is fast, lightweight, and nearly every Node.js deployment already has Redis available for caching or session storage.
The Architecture in Plain Terms
Before writing code, understand the topology:
- Each Node.js instance runs a WebSocket server (
socket.ioor rawws). - A Redis channel acts as a shared broadcast medium.
- When Instance A wants to emit an event, it publishes to Redis rather than emitting directly.
- All instances (including A itself) are subscribed to that Redis channel and forward incoming messages to their locally connected sockets.
Every instance becomes a relay. No instance needs to know what sockets are connected to the others.
Setting Up the Redis Adapter
The cleanest approach with socket.io is the official @socket.io/redis-adapter package, which implements this pattern for you. But understanding what it does under the hood matters when things go wrong.
// server.js
import { createServer } from "http";
import { Server } from "socket.io";
import { createAdapter } from "@socket.io/redis-adapter";
import { createClient } from "redis";
const httpServer = createServer();
const io = new Server(httpServer, {
cors: { origin: "*" },
});
// Redis requires two separate client instances:
// one for publishing, one for subscribing.
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
io.on("connection", (socket) => {
console.log(`Socket connected: ${socket.id}`);
socket.on("send-message", async ({ roomId, message }) => {
// This emit now broadcasts across ALL instances
io.to(roomId).emit("new-message", { message, from: socket.id });
});
});
httpServer.listen(3000);
Two Redis clients are required by design — a subscriber connection cannot issue commands while in subscribe mode, so the roles must be separated.
Room-Based Broadcasting
Rooms are the correct primitive for targeting groups of users — a chat thread, a shared document session, a dashboard tied to a specific tenant. With the Redis adapter in place, rooms work transparently across instances.
socket.on("join-room", (roomId) => {
socket.join(roomId);
});
When any instance calls io.to(roomId).emit(...), the adapter serializes the event, publishes it to Redis, and every instance deserializes and delivers it to whichever local sockets are in that room. From the application layer, it looks identical to single-server behavior.
What the Redis Adapter Does Not Solve
Using the adapter does not eliminate all distributed state problems. Watch for these:
- Presence tracking — knowing who is online across all instances requires a shared store (Redis sorted sets or hash maps work well here). Do not rely on in-memory socket counts.
- Sticky sessions — some teams configure their load balancer to pin clients to a specific instance. This reduces the frequency of cross-instance broadcasts but creates uneven load and single points of failure per user group. It is an optimization, not a substitute for a proper adapter.
- Event ordering — Redis Pub/Sub does not guarantee ordering across channels. If strict message ordering matters (financial feeds, collaborative editing), introduce a sequence number on the client or use Redis Streams instead.
- Connection storms — if all instances restart simultaneously (a common deployment scenario), every client reconnects at once. Implement exponential backoff with jitter on the client and set
socket.io'spingTimeoutandpingIntervalconservatively in production.
Monitoring and Observability
A WebSocket connection is long-lived, which means traditional request/response monitoring misses a lot. Add these instrumentation points:
- Active connections per instance — expose a
/metricsendpoint with the currentio.engine.clientsCount. - Redis Pub/Sub lag — measure the time between a publish and the corresponding emit on the subscriber side. Spikes here indicate Redis saturation.
- Disconnection rates — a sudden spike in disconnections usually precedes user complaints and can signal network partitions or memory pressure.
Tools like Prometheus with prom-client integrate cleanly into Node.js services and surface these metrics to Grafana dashboards without significant overhead.
Choosing Between socket.io and Raw ws
socket.io adds protocol negotiation, automatic reconnection, rooms, and the adapter ecosystem. For most product teams building SaaS features — notifications, live dashboards, collaborative tools — that overhead is worth the saved engineering hours.
Raw ws is appropriate when you are building infrastructure, need the absolute minimum wire overhead, or are integrating with clients that speak standard WebSocket protocol only. In that case, you implement the Redis fan-out logic yourself, which is roughly what @socket.io/redis-adapter encapsulates.
Why This Matters for Your Project
Real-time features are increasingly a baseline expectation in SaaS products — live order updates, collaborative editing, instant notifications. The gap between a convincing local demo and a production-stable implementation is almost always infrastructure topology, not application logic. Getting the Redis Pub/Sub layer right early means your WebSocket architecture scales linearly with your instance count, costs nothing extra in code complexity, and gives your ops team clean primitives to monitor. Build the relay correctly once, and horizontal scaling stops being a WebSocket problem at all.





