Scaling Socket.IO Beyond One Process: The Redis Adapter, Explained

Scaling Socket.IO Beyond One Process: The Redis Adapter, Explained - Build Archive

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-adapter is 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: true

Client 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: false

Exactly 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 connect handler, 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.

Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *