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.
The Test I Haven’t Gotten to Run
Here’s the honest gap. On paper, the way to prove this works is: run two client connections pinned to two different workers, broadcast from one, and watch both receive it —
// probe.js — connect twice, log which client gets the broadcast
import { io } from "socket.io-client";
for (let i = 0; i < 2; i++) {
const s = io("https://localhost:3000");
s.on("connect", () => console.log(`client ${i} on socket ${s.id}`));
s.on("ping-all", (msg) => console.log(`client ${i} got:`, msg));
}— then confirm with pm2 ls that the two clients really landed on different processes before trusting the result. I know this is the right test because it’s the only way to distinguish “the adapter works” from “both clients happened to hit the same worker and it would have worked anyway” — a false positive that’s easy to walk into. I just haven’t had a real multi-worker workload to run it against yet. If tickets.cristobal.cc ever outgrows one process, this is the first thing I’ll do before trusting the deploy, and I’ll update this post with what actually happened.
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.