Socket.IO is a library for event-based, bidirectional messaging between a server and browsers or mobile apps. On one machine it feels like magic: socket.emit('score', data) on the server and a callback fires on the client. At scale it becomes a distributed system whose unit of state is a long-lived connection rather than a request, and every hard problem follows from that. Connections are pinned to a node, broadcasts must cross nodes, and a single node restart sends tens of thousands of clients back at the fleet within seconds.
This article explains the moving parts from the wire up: what an Engine.IO session is and why load balancers must be sticky, how adapters turn io.to(room).emit() into a fleet-wide broadcast, what connection state recovery does and which adapters support it, and how to size and operate a fleet for the reconnection storm that defines this kind of system. Figures and option names here are checked against the Socket.IO v4 documentation and the Engine.IO source; where behaviour depends on your adapter version, the text says so.
What a Socket.IO connection is on the wire
Socket.IO sits on top of Engine.IO, which owns the transport. A client opens with an HTTP long-polling request. The server answers with an open packet carrying a session id (sid), the ping interval, the ping timeout and the maximum payload size. The client then probes a WebSocket connection with the same sid; if the probe succeeds, Engine.IO flushes the polling buffer and switches transports without losing the session. Polling first is deliberate: it works through corporate proxies that break WebSockets, and the upgrade happens only when the network allows it.
Engine.IO v4 defaults, from the server source, are a pingInterval of 25,000 ms, a pingTimeout of 20,000 ms, an upgradeTimeout of 10,000 ms, a maxHttpBufferSize of 1 MB, and per-message deflate switched off. The server sends a ping every interval; if no pong arrives within the timeout the connection is declared dead. That means a silently vanished client is detected in up to 45 seconds with defaults, which is the number to quote when product asks how fast presence goes stale.
Above the transport, Socket.IO adds namespaces (separate channels such as /chat and /admin, each with its own middleware), rooms (server-side sets of socket ids; joining is a set insert and is never visible to the client), and acknowledgements (a callback or promise attached to an event, giving request/response semantics on top of the stream). Every socket automatically joins a room named after its own id, which is how direct messages to a socket work across nodes.
What a Socket.IO connection is on the wire
Socket.IO sits on top of Engine.IO, which owns the transport. A client opens with an HTTP long-polling request. The server answers with an open packet carrying a session id (sid), the ping interval, the ping timeout and the maximum payload size. The client then probes a WebSocket connection with the same sid; if the probe succeeds, Engine.IO flushes the polling buffer and switches transports without losing the session. Polling first is deliberate: it works through corporate proxies that break WebSockets, and the upgrade happens only when the network allows it.
Engine.IO v4 defaults, from the server source, are a pingInterval of 25,000 ms, a pingTimeout of 20,000 ms, an upgradeTimeout of 10,000 ms, a maxHttpBufferSize of 1 MB, and per-message deflate switched off. The server sends a ping every interval; if no pong arrives within the timeout the connection is declared dead. That means a silently vanished client is detected in up to 45 seconds with defaults, which is the number to quote when product asks how fast presence goes stale.
Above the transport, Socket.IO adds namespaces (separate channels such as /chat and /admin, each with its own middleware), rooms (server-side sets of socket ids; joining is a set insert and is never visible to the client), and acknowledgements (a callback or promise attached to an event, giving request/response semantics on top of the stream). Every socket automatically joins a room named after its own id, which is how direct messages to a socket work across nodes.
Sticky sessions and the load balancer
A polling session is a series of separate HTTP requests that must all reach the node that holds the sid. If a request lands elsewhere, that node has never heard of the session and replies with HTTP 400 and a "Session ID unknown" error, and the client reconnects from scratch. So the load balancer must route by affinity: a cookie set at the first response, or a hash of the client address when cookies are impossible. IP hashing is weaker, because mobile carriers and corporate NATs put thousands of users behind one address and skew the distribution.
upstream socketio_nodes {
hash $remote_addr consistent; # or a sticky cookie on balancers that support it
server 10.0.1.11:3000;
server 10.0.1.12:3000;
server 10.0.1.13:3000;
}
server {
location /socket.io/ {
proxy_pass http://socketio_nodes;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 75s; # longer than pingInterval + pingTimeout
}
}There is an escape hatch. If every client is configured with transports: ['websocket'], there is no polling phase, each session is a single TCP connection, and the balancer pins it naturally without affinity rules. You lose the fallback for networks that block WebSockets, which in 2026 is a small but non-zero share of enterprise users. Many teams run WebSocket-only for their mobile apps, where they control the network stack, and keep polling for the browser client. The proxy read timeout must exceed the heartbeat window, or the proxy itself kills idle-looking connections and you will see a disconnect every sixty seconds that no Socket.IO setting explains.
Adapters: one broadcast, many nodes
Rooms live in memory on each node, so a broadcast on node A only reaches sockets on node A. The adapter is the pluggable component that fixes this. When you call io.to('match:42').emit('goal', data), the local adapter delivers to local members and also publishes the packet to the other nodes, each of which delivers to its own members. Application code stays topology-blind.
| Adapter | Transport | Recovery support | When to use it |
|---|---|---|---|
| In-memory (default) | none | yes, single node | one process, or development |
| Redis adapter | Redis pub/sub | no | the common default for multi-node fleets |
| Redis adapter, sharded | Redis 7 sharded pub/sub | no | very high publish rates on Redis Cluster |
| Redis Streams adapter | Redis streams | yes | multi-node with connection state recovery |
| MongoDB adapter | change streams | yes (0.3.0 and later) | Mongo-centric stacks |
| Cluster adapter | Node IPC | listed as work in progress | Node cluster workers on one host |
The Socket.IO documentation states the reason the classic Redis adapter cannot support recovery: pub/sub is fire-and-forget, so there is no stored packet to replay to a client that missed it. The streams adapter writes every broadcast to a Redis stream that nodes read with offsets, which costs memory and stream trimming but gives a replayable log. Check the support matrix for your exact adapter version before you depend on it, because it has changed across releases.
Adapters also carry cluster-wide queries. io.in('match:42').fetchSockets() asks every node for its matching sockets and waits for replies, io.serverSideEmit() sends an event to the other servers rather than to clients, and socketsJoin or disconnectSockets act on remote sockets. These are request/response over the bus with a timeout; calling fetchSockets on every incoming message is a classic way to turn Redis into the bottleneck.
import { createServer } from "node:http";
import { Server } from "socket.io";
import { createClient } from "redis";
import { createAdapter } from "@socket.io/redis-adapter";
const pub = createClient({ url: process.env.REDIS_URL });
const sub = pub.duplicate();
await Promise.all([pub.connect(), sub.connect()]);
const io = new Server(createServer(), {
adapter: createAdapter(pub, sub),
pingInterval: 25000,
pingTimeout: 20000,
maxHttpBufferSize: 1e5, // tighten from 1 MB unless you really send big payloads
});
io.use(async (socket, next) => { // auth runs once per connection, not per event
try { socket.data.user = await verify(socket.handshake.auth.token); next(); }
catch { next(new Error("unauthorized")); }
});
io.on("connection", (socket) => {
socket.on("watch", (matchId, ack) => {
socket.join(`match:${matchId}`);
ack({ ok: true });
});
});
io.listen(3000);
Connection state recovery
Socket.IO 4.6.0 added connection state recovery. When enabled, the server keeps a disconnected session's id, rooms, socket.data and the packets it missed for a configurable window, and a client that reconnects within that window gets them back instead of a fresh session. The client tracks the offset of the last packet it received and presents it on reconnect.
const io = new Server(httpServer, {
adapter: createAdapter(redisClient), // from @socket.io/redis-streams-adapter
connectionStateRecovery: {
maxDisconnectionDuration: 2 * 60 * 1000, // the documented default
skipMiddlewares: true, // recovered sockets skip auth middleware
},
});
io.on("connection", (socket) => {
if (socket.recovered) {
// rooms and socket.data restored, missed packets replayed: do nothing
} else {
// new or unrecoverable session: rejoin rooms, send a full snapshot
sendSnapshot(socket);
}
});Recovery is an optimisation, never a guarantee. Sessions expire, a node restart with the in-memory adapter loses them, and some events are excluded from the buffer. Your application must still handle the "not recovered" branch with a full state snapshot, and clients should treat events as idempotent because a replay can overlap with what they already applied. skipMiddlewares: true is convenient but means a revoked token stays valid across a recovery; set it to false if revocation latency matters.
Worked example: 200,000 sockets and a node failure
Take a live-sports app at peak: 200,000 concurrent sockets on eight nodes, so 25,000 per node, with the Redis adapter. A popular match room has 50,000 watchers spread over all eight nodes, and the feed emits about 10 events per second.
- Heartbeats: one ping per socket every 25 seconds is 1,000 pings per second per node. Negligible, which is why you should not shorten the interval to fix presence; fix it in the presence layer.
- Broadcast cost: each event is one Redis publish, eight subscriber deliveries, and about 6,250 socket writes per node. Socket.IO encodes a broadcast packet once and writes the same frame to every local member, so the cost is dominated by syscalls and TLS, not JSON. At 10 events per second that is roughly 62,500 writes per second per node for this room alone.
- Redis load: 10 publishes per second for the room, each fanned to eight subscribers. Redis is not the bottleneck here; it becomes one when many small rooms each publish frequently, because every publish reaches every node whether or not it has members in that room.
- Node death: 25,000 clients reconnect. With the client defaults of
reconnectionDelay: 1000,reconnectionDelayMax: 5000andrandomizationFactor: 0.5, the first attempts spread over about one second, so each of the seven survivors absorbs roughly 3,600 handshakes, TLS negotiations and auth calls in that second, plus room joins and snapshot requests.
The last line is the sizing constraint. Steady state might use 25% CPU, but the fleet must survive one node's worth of reconnects landing on the others, and must survive it during a deploy, when you restart nodes deliberately. Run each node at a socket count where a 15% surge in a second stays below saturation, make auth cheap (a local JWT check rather than a database call), and roll deploys one node at a time with a drain period in which the old node stops accepting and disconnects its clients gradually.
Slow consumers and backpressure
A slow consumer is a client whose network cannot keep up. Socket.IO buffers its outgoing packets in memory, so a thousand mobile clients on bad cellular links can quietly grow a node's heap until it falls over. Three tools limit this. socket.volatile.emit() drops the packet if the transport is not ready, which is right for high-frequency position or price updates where only the latest value matters. socket.timeout(5000).emitWithAck() gives end-to-end confirmation with a deadline, so critical events can be retried or reported. And maxHttpBufferSize caps what a client may send, protecting the server from a single oversized message.
try {
const reply = await socket.timeout(5000).emitWithAck("order:update", order);
metrics.ackLatency.observe(Date.now() - start);
} catch (err) {
// no ack within 5 s: client gone or overloaded; queue for redelivery on reconnect
outbox.enqueue(socket.data.user.id, order);
}
Failure modes
- Missing stickiness: intermittent 400 "Session ID unknown" errors and clients that connect, drop and reconnect in a loop; often appears only after scaling from one node to two.
- Single-node tests: broadcasts reach a fraction of the audience in production because the adapter was never configured; tests with one process cannot catch it. Run integration tests with two nodes.
- Thundering reconnect: a deploy restarts all nodes together and auth or the database collapses under the herd, causing more disconnects. Roll one node at a time and keep client jitter on.
- Proxy idle timeout: disconnects at exactly 60 seconds come from the proxy, not Socket.IO.
- Heap growth from slow clients: unbounded per-socket buffers; use volatile emits and monitor buffered bytes.
- Redis saturation: per-message
fetchSocketscalls or very many busy rooms; move presence to a dedicated store and consider the sharded adapter. - Recovery assumed: clients that never handle the non-recovered branch show stale state after any outage longer than the window.
Operating the fleet
Watch connected sockets per node and their distribution (a skewed balancer shows up here first), event-loop lag (the single best saturation signal for a Node.js process), connection and disconnection rates with disconnect reasons such as ping timeout versus transport close, the share of sessions still on polling, adapter publish rate, Redis CPU, and process memory. The connection_error event on io.engine exposes handshake failures with codes, which is how you spot a stickiness problem without reading load balancer logs. Load test with a realistic reconnect scenario, not just steady state: kill a node under load and measure how long the fleet takes to return to baseline.
Trade-offs
| Choice | Gain | Cost |
|---|---|---|
| Polling plus upgrade | works through hostile proxies | needs sticky routing |
| WebSocket only | no affinity rules, less overhead | fails where WebSockets are blocked |
| Redis pub/sub adapter | simple, fast, widely used | no connection state recovery |
| Redis Streams adapter | replayable, supports recovery | stream memory and trimming to manage |
| Fewer, larger nodes | less adapter traffic | bigger reconnect storm per failure |
| Socket.IO over raw WebSocket | rooms, acks, reconnection built in | custom protocol; plain WS clients cannot talk to it |
For the protocol-level comparison see Socket.IO versus native WebSocket; for the general scaling patterns behind this article read horizontal WebSocket scaling, load balancer pitfalls, reconnection strategies and presence tracking.
What to do next
- Decide per client whether you need the polling fallback; if not, set WebSocket-only and drop affinity rules.
- If you keep polling, configure cookie stickiness and verify it with a two-node test.
- Set the proxy read timeout above pingInterval plus pingTimeout.
- Pick an adapter by whether you need recovery: Redis Streams or MongoDB if yes, Redis pub/sub if no.
- Implement the non-recovered branch with a full snapshot and make client event handlers idempotent.
- Use volatile emits for high-frequency state and emitWithAck with a timeout for must-arrive events.
- Size nodes to absorb one failed node's reconnects, and load test by killing a node under load.
- Dashboard sockets per node, event-loop lag, disconnect reasons and adapter publish rate.