Socket.IO rooms with a shared adapter: minimal real-time fan-out architecture
Minimal Socket.IO architecture using rooms for fan-out and a shared Redis adapter for horizontal scale, with handshake authentication, best-effort connection state recovery, and clear failure modes.
21 Jan 2026, 00:49 UTC

The problem and the practical takeaway
You need low-latency fan-out to many clients per tenant, document, or channel without building a custom broker. With Socket.IO the smallest viable design is rooms for fan-out plus a shared adapter for horizontal scale, with connection state recovery used only as a best-effort reconnect aid. Recovery is not durability.
Requirements that shape the design
Fan-out is the core requirement: one server event must reach many sockets that share a domain identifier. Targeting must be per-connection for direct messages and per-group for broadcasts. The system must survive a single process restart and scale beyond one Node process. Authentication must happen before any room membership is granted. Delivery semantics are best-effort; ordering is preserved per socket but not guaranteed across reconnects.
Assume Socket.IO server v4.x and client v4.x. Connection state recovery exists from server v4.6.0 onward. Confirm installed versions before relying on it.
Smallest suitable design
One namespace, rooms keyed by domain
Use a single namespace unless you need strict isolation of events. Key rooms by a domain identifier you control, for example tenant:123 or doc:abc. Never trust a client-supplied room name for membership.
Each socket auto-joins a room named after its own id, which makes per-socket targeting simple via io.to(socket.id). Server-side join and leave are driven by authenticated identity.
// server.js - run as a Node process with network access to Redis
const { Server } = require('socket.io');
const { createAdapter } = require('@socket.io/redis-adapter');
const io = new Server(httpServer, {
connectionStateRecovery: { maxDisconnectionDuration: 30_000 }
});
io.use((socket, next) => {
// authenticate in handshake middleware
const token = socket.handshake.auth.token;
// validate token, load user id and tenant
socket.data.userId = 'u1';
socket.data.tenantId = 't1';
next();
});
io.on('connection', (socket) => {
const room = `tenant:${socket.data.tenantId}`;
socket.join(room);
// rooms are removed automatically on disconnect;
// call socket.leave(room) explicitly on logout
});Use plain events for one-way pushes. Add acknowledgements only where the client needs a confirmed result from the server.
Horizontal fan-out with a shared adapter
The default in-memory adapter only reaches sockets on the same process. For multiple instances use a shared adapter, commonly Redis-backed, and keep sticky sessions at the load balancer while HTTP long-polling is in use.
Sticky sessions are required for the initial handshake when long-polling is allowed. Without them you will see handshake errors such as Session ID unknown, because a polling request lands on a different node than the one holding the session.
Adapter setup is done once at server start; the adapter then handles inter-node pub/sub for room broadcasts.
Connection state recovery as best-effort
With connectionStateRecovery enabled, a client that reconnects within maxDisconnectionDuration can rejoin its rooms and receive missed packets buffered by the adapter. It is best-effort and depends on adapter support. It does not replace application-level acknowledgement or persistence, and packets beyond the window are dropped.
On the server you can inspect socket.recovered to tell whether a connection is a recovery.
Trust and data boundaries
Authenticate in handshake middleware, before connection fires. Derive room membership and user identity server-side from the authenticated principal, not from client messages.
Treat all client-supplied event names, room names, and payloads as untrusted. Validate schema and size-limit payloads. Avoid storing large per-socket state in process memory; it complicates scaling and restarts.
Room names are internal routing keys. Expose only opaque channel identifiers to clients and map them server-side.
Operational checks
- Concurrent connections and reconnection rate per node and per tenant.
- Adapter pub/sub latency and errors; monitor Redis command latency and dropped messages.
- Event-loop lag and memory growth per process.
- Per-room membership size to detect hot rooms.
Load-test with realistic fan-out patterns, not single-client smoke tests. Verify cross-node broadcasts by connecting clients to different nodes behind the load balancer and emitting from a third node.
Practical verification steps:
- Check installed server and client versions and confirm the feature set matches the release line.
- Run two server instances behind a load balancer, connect clients over long-polling, then confirm transport upgrades and cross-node broadcasts arrive.
- Force a disconnect inside the recovery window and inspect the recovered flag and room membership on reconnect.
- Stop the shared adapter and observe whether broadcasts still reach clients on other nodes, confirming the split-broadcast failure mode.
Failure modes to expect
Missing sticky sessions cause handshake errors and failed upgrades. An adapter outage silently splits broadcasts: each node emits only to its local sockets. Rooms never left on explicit logout can leak memory in the adapter. Recovery drops packets beyond the configured window and does not guarantee ordering across a reconnect.
Long-polling fallback and WebSocket-only modes impose different proxy timeout and sticky-session requirements. Validate in the target environment rather than assuming parity across adapter backends.
When to change the design
Move to a durable log or broker-backed stream when you need at-least-once delivery, total ordering, or replay beyond the recovery window. Socket.IO recovery is a transient buffer, not a durable queue.
Shard by region or namespace when a single adapter becomes the bottleneck or latency grows. Consider per-tenant namespaces or separate deployments for isolation.
Avoid Socket.IO for large file transfer or persistent event sourcing; those workloads need different primitives.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.