Connecting a Fleet: How Redis Pub/Sub Makes Socket.IO Scale Across Containers
The core challenge of scaling Socket.IO is that each server instance is an isolated island. Here's how we used @socket.io/redis-adapter and the Socket.IO v4 upgrade to bridge those islands into one unified real-time system.

Connecting a Fleet: How Redis Pub/Sub Makes Socket.IO Scale Across Containers
The core challenge of scaling Socket.IO has nothing to do with performance. It's about isolation.
Each Socket.IO server process maintains its own in-memory registry of connected clients. When you emit an event, it fans out to every client in that process's registry. That works perfectly with one server. It breaks completely with two.
Here's the problem in concrete terms. Imagine a live auction room with 200 bidders connected. 100 of them are on Container A, 100 on Container B. A bidder on Container A places a bid. Container A emits bid:new to its local 100 clients. The other 100 clients on Container B never receive it. Half the room sees the new bid price, the other half sees nothing. The auction state is now split.
This was the architectural ceiling of our Socket.IO v2 setup. Solving it required two things: upgrading Socket.IO from v2 to v4, and integrating the Redis adapter.
Context: Part 3 of the series on rebuilding our real-time auction platform. See the full Bidding Engine Redesign case study and Part 2: Architecture Overview.
Why the v2 → v4 Upgrade Came First
Socket.IO v4 ships with official first-party support for @socket.io/redis-adapter. In v2, cross-instance communication required socket.io-redis, a third-party adapter with a different API and meaningfully less stability under high concurrency. We'd evaluated it previously and decided against it because the operational risk wasn't worth the benefit.
With Socket.IO v4, the @socket.io/redis-adapter is officially maintained by the Socket.IO team, uses modern Redis client patterns, and supports the createAdapter API that makes integration straightforward.
The v2 to v4 upgrade also brought:
- A significantly improved WebSocket reconnection algorithm with exponential backoff and jitter
- Better connection stability on inconsistent networks, relevant for mobile bidders
- The
io.to()andio.in()room APIs stabilised with cleaner semantics - Native TypeScript types
We ran the upgrade in a staging environment with production-level traffic simulation before touching production. The Socket.IO client and server versions need to match, so the upgrade required a coordinated deployment of both.
How the Redis Adapter Works
The @socket.io/redis-adapter intercepts every emit() call and, instead of only broadcasting locally, publishes the event to a Redis Pub/Sub channel. Every other Socket.IO server instance subscribed to that channel receives the event and broadcasts it to its own local clients.
The flow for a bid event:
This is the key insight: every Socket.IO instance sees every event, regardless of which instance the originating client is connected to. Redis is the message bus between isolated processes.
Under the hood, the adapter uses two Redis channels per Socket.IO namespace: one for regular events (socket.io#/#) and one for acknowledgements. When you broadcast to a room (io.to('auction:123').emit(...)), the adapter serialises the event and publishes to the namespace channel. Each subscriber deserialises it and forwards to clients in that room who are locally connected to that instance. The originating instance is excluded from re-broadcasting via the adapter's own socket ID tracking.
Setting Up the Redis Adapter
The integration is straightforward once the upgrade is complete:
import { createServer } from "http";
import { Server } from "socket.io";
import { createAdapter } from "@socket.io/redis-adapter";
import { createClient } from "redis";
const httpServer = createServer();
const io = new Server(httpServer, {
cors: { origin: process.env.ALLOWED_ORIGINS?.split(",") ?? [] },
transports: ["websocket", "polling"],
});
// Redis requires separate client connections for pub and sub
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
httpServer.listen(3000);
The critical detail: Redis requires separate client connections for publishing and subscribing. Using the same connection for both causes silent failures under load. This is a common mistake that only surfaces under concurrency. We learned this one in staging. It's covered in detail in Part 5.
Sticky Sessions: Why They're Non-Negotiable for WebSockets
The Redis adapter solves the broadcast problem. But there's a second, independent problem: WebSocket connections themselves.
A WebSocket connection is a persistent, stateful TCP connection. Unlike HTTP requests, WebSocket connections can't be freely redirected mid-session. Once a client completes the WebSocket upgrade handshake with a specific server, the connection is bound to that server for its lifetime.
Without sticky sessions on the ALB, this scenario breaks things:
- Client initiates WebSocket upgrade → ALB routes to Node 1
- Connection drops (network blip, mobile handoff, anything)
- Client reconnects → ALB routes to Node 2 (different node, no stickiness)
- Node 2 has no record of this client's previous session → context loss, potential error
With sticky sessions (cookie-based, via the AWSALB cookie), the ALB routes all requests from the same client to the same Fargate task. If that task goes down, the ALB detects it via health check, deregisters it, and routes the client to a new task. The client gets a fresh session, not a broken half-reconnect.
The ALB target group configuration:
stickiness.enabled: true
stickiness.type: lb_cookie
stickiness.lb_cookie.duration_seconds: 86400 # 24 hours
One nuance worth calling out: sticky sessions are a transport mechanism, not an architectural dependency. Our containers are stateless in the meaningful sense, no critical data lives locally in the process. Sticky sessions keep WebSocket transport stable; they're not holding state. The state lives in Redis.
Presence and Auction State in Redis
Beyond event broadcasting, we moved auction presence state explicitly into Redis:
socket.on("auction:join", async ({ auctionId }) => {
await socket.join(`auction:${auctionId}`);
// Increment presence counter in Redis, shared across all nodes
await redisClient.hincrby(`auction:${auctionId}:presence`, "viewers", 1);
// Broadcast updated count to all nodes via the adapter
io.to(`auction:${auctionId}`).emit("presence:update", {
viewers: await redisClient.hget(`auction:${auctionId}:presence`, "viewers"),
});
});
socket.on("disconnect", async () => {
// Decrement on disconnect, any node can handle this
for (const room of socket.rooms) {
if (room.startsWith("auction:")) {
const auctionId = room.replace("auction:", "");
await redisClient.hincrby(`auction:${auctionId}:presence`, "viewers", -1);
}
}
});
The presence counter lives in Redis, not in any single Node's memory. Every container can read it, write to it, and broadcast updates. No container has a stale view.
The End-to-End Bid Flow
Putting it all together, here's what happens from the moment a bidder submits a bid to the moment every connected client sees the update:
- Bidder submits bid via the app → Socket.IO client emits
bid:placeto their connected Fargate task - The RTC task validates the event, forwards it to the E-Bidding Platform API for business logic
- The API validates the bid, updates the database, and emits a confirmation event back to the RTC layer
- The RTC task emits
bid:newto the auction room viaio.to(auctionRoom).emit() - The Redis adapter publishes this to the Redis
socket.iochannel - All other RTC tasks receive the Redis subscription event
- Each task emits
bid:newto their locally connected clients in that auction room - Every bidder in the room, regardless of which container they're connected to, sees the updated bid within ~100ms
The isolation problem that broke our old architecture simply doesn't exist in this design. Redis is the shared state layer; the containers are interchangeable delivery mechanisms.
Next in the series: What Happens When a Container Dies Mid-Auction: Resilience Engineering in Practice →