Full disclosure before this post starts giving advice: the real-time app I actually run — a ticketing tool with live chat, typing indicators and presence over Socket.IO — runs as a single process. It doesn’t need what this post is about, and I’d rather say that plainly than borrow authority I haven’t earned. What follows is the setup I’d reach for the day that stops being true, worked through carefully enough that I’m confident in it — and precise about the one piece I can’t verify without the multi-worker traffic to test it against.
Why One Process Stops Being Enough
Node is single-threaded; one process saturates one core. The moment you run more than one — PM2 cluster mode on a bigger box, or multiple containers behind a load balancer — each process holds its own set of WebSocket connections in memory, and two things break at once:
- Broadcasts stop crossing processes.
io.to("ticket-42").emit(...)only reaches sockets connected to that process. A staff member and a client on the same ticket, connected to different workers, stop seeing each other’s messages live. - The long-polling fallback breaks outright. Socket.IO’s handshake is stateful — a polling session opened on worker A returns errors if the next poll lands on worker B.
Two independent fixes, and it’s easy to ship half of one and call it done — which is worse than shipping neither, because it works in testing and fails only under real concurrent load.
Half One: A Shared Adapter
Socket.IO’s default adapter is in-memory and process-local. Swapping it for the Redis adapter routes every emit through pub/sub so all workers see it:
import { createServer } from "http";
import { Server } from "socket.io";
import { createClient } from "redis";
import { createAdapter } from "@socket.io/redis-adapter";
const httpServer = createServer(app);
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
const io = new Server(httpServer, {
adapter: createAdapter(pubClient, subClient),
});That’s the entire application-code change. Two things worth knowing before reaching for it:
- Two Redis connections per worker — pub/sub needs the subscriber client kept separate, so budget 2 × workers.
@socket.io/redis-streams-adapteris worth a look over the classic pub/sub one. Pub/sub drops events published while a worker is briefly disconnected from Redis; the streams-based adapter can catch up after the fact. For most apps that gap never matters — but for a ticketing tool specifically, where “a message got lost” is the one failure mode users actually notice, I’d pick the streams adapter if I ever build this out. That’s a judgment call from reading the docs carefully, not from having watched it fail the other way — worth saying plainly rather than dressing it up as hard-won experience.
Half Two: Sticky Sessions
PM2 cluster mode on one machine handles this for you — the master distributes connections, not requests, so a client’s session naturally stays on one worker. Across multiple machines behind Nginx, you have to pin clients yourself:
upstream socket_nodes {
ip_hash;
server 10.0.0.11:3000;
server 10.0.0.12:3000;
}
server {
location /socket.io/ {
proxy_pass http://socket_nodes;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 60s;
}
}Or sidestep it: force transports: ["websocket"] client-side (no long-polling fallback) and stickiness stops mattering, because the connection is one long-lived TCP stream that never needs to be re-matched to a worker. The cost is dropping support for networks that block raw WebSocket upgrades — rare, but not zero, mostly restrictive corporate proxies.
Update: I Ran the Test
I didn’t want to wait for tickets.cristobal.cc to actually outgrow one process just to find out if the paragraph above was right, so I built the minimum version of it standalone: two plain Node processes, no PM2, no Nginx — just two Socket.IO servers on different ports, both wired to the same Redis adapter, so the load-balancer layer couldn’t hide or fake the result either way.
// server.js — same file, started twice with different PORT/WORKER_NAME
const io = new Server(httpServer, {
adapter: createAdapter(pubClient, subClient),
});
io.on("connection", (socket) => {
socket.join("ticket-42");
socket.on("broadcast-from-here", (msg) =>
io.to("ticket-42").emit("ping-all", { from: WORKER, msg })
);
});$ PORT=4001 WORKER_NAME=worker-a node server.js &
$ PORT=4002 WORKER_NAME=worker-b node server.js &Then a client pinned to each port by connecting directly (the equivalent of two requests that ip_hash already routed to different backends), with the broadcast fired only from worker-a’s socket:
client A connected to worker-a, socket id _uLpeuEZ1ARD540fAAAA
client B connected to worker-b, socket id Y_rov5uAJQICOb9qAAAA
--- telling worker-a to broadcast to room ticket-42 ---
client A (on worker-a) received: { from: 'worker-a', msg: 'hello from a cross-worker test' }
client B (on worker-b) received: { from: 'worker-a', msg: 'hello from a cross-worker test' }
--- RESULT ---
client A (same worker as sender) got it: true
client B (different worker, via Redis adapter) got it: trueClient B was never told about the broadcast by anything except Redis — it holds a socket to worker-b only, and worker-b’s own code never called emit. That’s the actual claim from earlier in this post, confirmed rather than assumed: the adapter genuinely moves the event across the process boundary, not just across sockets that happened to share a process.
While the two workers were up, I also checked the failure mode I’d only described hypothetically below — stopped the Redis server mid-test and reran the same probe:
client A (same worker as sender) got it: true
client B (different worker, via Redis adapter) got it: falseExactly the partial-outage shape the “Failure Modes” section below describes: the local emit on worker-a still fired (client A got it — that’s a same-process delivery, the adapter was never in that path), and the cross-worker one silently vanished with no error surfaced to either client. Nothing in this test looked broken from inside worker-a’s own logs; you’d only notice from worker-b’s side, or from a user on the other socket wondering why their ticket went quiet. That’s not a theoretical risk I’m flagging out of caution anymore — it’s what actually happened the moment Redis wasn’t there.
What the Adapter Doesn’t Solve
It moves events between workers, not state. Anything held in a plain in-memory structure — who’s typing, who’s online, per-connection game state — is still local to one process and will desync the moment there’s more than one. The fix is the same discipline that makes any horizontally-scaled service sane: shared state lives in Redis (a hash with a TTL refreshed on heartbeat, say), and each worker holds only a cache of it, never the source of truth.
Failure Modes Worth Planning For Even Before You Need Any of This
- Redis goes down: with the pub/sub adapter, cross-worker broadcasts silently stop while local emits keep working — a partial outage that looks like “it’s fine” from inside one process. Alert on the adapter’s own error events, not just on Redis’s uptime.
- A worker restarts: clients reconnect automatically, but land on a possibly different worker with none of their previous room memberships. Re-join rooms in the client’s
connecthandler, or use Socket.IO’s built-in Connection State Recovery instead of hand-rolling it.
Scaling Socket.IO past one process isn’t hard, but it’s exact: shared adapter for events, stickiness (or websocket-only) for the handshake, Redis for anything that needs to be true across workers — and a real cross-worker test before anyone calls it done. I’d rather publish that plan honestly, gap included, than pretend I’d already run it.
