Scaling Socket.IO Beyond One Node: Choosing Between In-Memory, Sticky Sessions, and the Redis Adapter
A decision guide for scaling Socket.IO beyond a single Node.js process: compare in-memory, sticky sessions, and the Redis adapter with a concrete two-node validation test.
15 Aug 2025, 22:12 UTC

The Core Problem
Socket.IO stores connection state in the memory of the Node.js process that accepted the handshake. When you run multiple server instances—whether as worker threads, separate containers, or distinct hosts—a broadcast emitted from one process never reaches sockets held by another. Horizontal scaling therefore requires a deliberate architectural decision: how to route the initial handshake and how to propagate events across processes.
Constraints That Shape the Decision
- Transport fallback: Socket.IO starts with HTTP long-polling and upgrades to WebSocket. If the upgrade request lands on a different process than the handshake, the connection fails.
- Broadcast semantics:
io.to('room').emit(...)must reach every socket in that room, regardless of which process holds the connection. - Operational complexity: Adding an external dependency (Redis, NATS, etc.) increases deployment surface and failure modes.
- Latency budget: Each cross-process broadcast adds a network hop and serialization step.
Supported Options at a Glance
| Approach | Handshake Routing | Cross-Node Broadcast | External Dependency | Typical Use Case |
|---|---|---|---|---|
| Single Node (in-memory) | Trivial—only one process | Not applicable | None | Development, low-traffic internal tools |
| Sticky Sessions Only (IP hash, cookie affinity) | Solved by load balancer | Not solved—rooms stay local to each node | Load balancer config only | Rarely sufficient alone; only works if every room is confined to one node |
Redis Adapter (@socket.io/redis-adapter) | Requires sticky sessions or WebSocket-only transport | Solved via Redis pub/sub | Redis instance/cluster | Standard production scaling for Socket.IO v4+ |
| Alternative Adapters (NATS, MongoDB, cluster adapter) | Same as Redis adapter | Solved via respective backend | Varies (NATS server, MongoDB replica set, etc.) | When Redis is not in stack or specific features needed |
Trade-Offs Explained
Why Sticky Sessions Alone Are Not Enough
Configuring your load balancer (NGINX ip_hash, AWS ALB target group stickiness, etc.) ensures the handshake and upgrade requests hit the same Node process. However, each process still maintains its own room membership maps. A user joining room:A on node 1 is invisible to node 2, so io.to('room:A').emit() called on node 2 never reaches that user.
Redis Adapter: The Official Path
The @socket.io/redis-adapter package (maintained by the Socket.IO team) plugs into the Adapter interface. When any node calls emit or broadcast, the adapter serializes the packet and publishes it to a Redis channel. All other nodes subscribe to that channel, deserialize, and deliver locally. This makes rooms truly global.
Caveats:
- Redis pub/sub is fire-and-forget. If a node is down or the Redis connection blips, messages are lost—no replay.
- Every broadcast incurs a round-trip to Redis plus JSON serialization. For high-frequency, low-latency workloads (e.g., real-time gaming), evaluate NATS or a custom binary protocol.
- Adapter APIs changed between Socket.IO v3 and v4. Always check the README matching your installed version.
Alternative Adapters
If your infrastructure already runs NATS, Kafka, or MongoDB, community adapters (@socket.io/nats-adapter, socket.io-mongo-adapter, etc.) avoid adding Redis solely for Socket.IO. The cluster adapter (@socket.io/cluster-adapter) uses Node’s cluster module and shared memory, eliminating network hops but limiting you to a single host.
Concrete Validation: Two Nodes, One Redis, Cross-Instance Broadcast
The following steps verify that the Redis adapter works end-to-end. Run them on a machine with Node.js ≥ 18, Redis 6+, and npm.
1. Install Dependencies
npm init -y
npm install socket.io@4 @socket.io/redis-adapter@8 ioredis@52. Server Code (server.js)
const { createServer } = require('http');
const { Server } = require('socket.io');
const { createAdapter } = require('@socket.io/redis-adapter');
const Redis = require('ioredis');
const PORT = process.env.PORT || 3000;
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
const httpServer = createServer();
const io = new Server(httpServer, {
cors: { origin: '*' },
// Force WebSocket-only to avoid sticky-session requirement during test
transports: ['websocket']
});
const pubClient = new Redis(REDIS_URL);
const subClient = pubClient.duplicate();
io.adapter(createAdapter(pubClient, subClient));
io.on('connection', (socket) => {
console.log(`[${PORT}] Client connected: ${socket.id}`);
socket.join('global-room');
socket.on('ping-from-client', (msg) => {
console.log(`[${PORT}] Received:`, msg);
io.to('global-room').emit('pong-from-server', { from: PORT, payload: msg });
});
});
httpServer.listen(PORT, () => {
console.log(`Socket.IO server listening on ${PORT}`);
});3. Start Two Instances
# Terminal 1
PORT=3000 node server.js
# Terminal 2
PORT=3001 node server.js4. Connect Two Clients (using wscat or a tiny HTML page)
# Install wscat globally if needed
npm i -g wscat
# Terminal 3 – client attached to node 3000
wscat -c ws://localhost:3000/socket.io/?EIO=4&transport=websocket
# Terminal 4 – client attached to node 3001
wscat -c ws://localhost:3001/socket.io/?EIO=4&transport=websocketAfter each connection, you’ll see a JSON handshake. Send a Socket.IO packet (format: 42["ping-from-client",{"data":"hello"}]) from Terminal 3. Both terminals should receive a pong-from-server event showing the originating port (3000) and the payload. Repeat from Terminal 4 to confirm bidirectional propagation.
5. Inspect Redis Traffic (Optional)
redis-cli MONITORYou’ll see PUBLISH calls to channels like socket.io#/#global-room# whenever an emit occurs.
Limitations & Verification Checklist
- Adapter version drift: The
createAdaptersignature changed in v8.0.0 (Socket.IO v4). Pin versions inpackage.jsonand read the adapter’s changelog before upgrading. - Redis failover: Use Redis Sentinel or a managed cluster (ElastiCache, Azure Cache) with automatic failover. Test by killing the primary during a broadcast—expect message loss during the election window.
- File descriptors: Each WebSocket connection consumes a file descriptor. Raise
ulimit -nand configurenet.core.somaxconnon Linux hosts. - Sticky sessions vs. WebSocket-only: If you cannot guarantee sticky sessions (e.g., serverless platforms), force
transports: ['websocket']on both client and server. This avoids the polling-to-websocket upgrade race but excludes legacy clients.
Decision Summary
- If you run one Node process: do nothing—default in-memory adapter is correct.
- If you run multiple processes on one host and want zero external deps: try
@socket.io/cluster-adapter(shared memory). - If you run multiple hosts or containers: deploy the Redis adapter with sticky sessions or WebSocket-only transport.
- If Redis is operationally unacceptable: evaluate NATS or MongoDB adapters, but verify their maintenance status and Socket.IO version compatibility.
Validate with the two-node test above before committing to production. The test proves that rooms span processes and that your Redis connectivity, serialization, and firewall rules are correct.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.