Scaling Socket.IO Horizontally: Why Your Real-Time Architecture Breaks Beyond a Single Process A developer demonstrates why Socket.IO real-time architectures break when scaled horizontally beyond a single Node.js process, since each instance's shared-nothing memory means broadcasts and room joins only reach clients connected to that same process. The writeup shows how to reproduce the failure locally by running two server instances on different ports, and argues that horizontally scaled real-time apps require a centralized message bus outside the application layer. WebSockets are the foundation of modern interactive web apps. Whether you are synchronizing live cursor movements in a collaborative canvas, streaming inference progress from a background AI job, pushing real-time order tracking updates, or delivering financial ticks, persistent full-duplex TCP connections make instant client updates trivial. During early development, building these features with Socket.IO in Node.js feels seamless. You initialize an HTTP server, attach a Socket.IO instance, listen for incoming connections, and push updates using io.emit or room-based targeting: // A typical single-server broadcast io.to "project:402" .emit "task updated", { status: "completed" } ; On a single development server, this works flawlessly. The server receives the update, locates all connected clients listening on project:402, and pushes the payload down their active TCP connections. However, this architecture relies on a silent, fragile assumption: every connected client lives in the same process memory. A single Node.js process runs on a single thread and is bounded by operating system memory limits typically 1.4 GB to 2 GB of V8 heap by default . As active concurrent connections grow from hundreds to tens of thousands, a single CPU core becomes a hard throughput bottleneck. To scale, you do what every production engineer does: scale horizontally. You spin up multiple Node.js worker processes across multiple CPU cores using PM2 or Docker containers, placing them behind a reverse proxy like Nginx or an AWS Application Load Balancer. The moment you introduce that second server instance, your real-time communication silently breaks. Node.js processes adhere strictly to a shared-nothing architecture. Process A and Process B inhabit isolated virtual memory spaces. They cannot inspect, access, or manipulate each other's data structures. When Client A and Client B land on different instances: Client A establishes a WebSocket connection routed by the load balancer to Node Instance 1. Instance 1 allocates a socket reference in its local heap memory. Client B connects and is routed to Node Instance 2. Instance 2 records Client B in its own separate heap. When Client A performs an action that triggers io.emit 'event', payload inside Instance 1, Instance 1 can only iterate over its own local registry. Instance 1 has no visibility into Instance 2. As a result, the event is dispatched to Client A and anyone else attached to Instance 1 , while Client B never receives the payload. The exact same breakdown occurs with Socket.IO rooms socket.join 'room-name' . If two users join the same logical room on different physical servers, the room exists only as a local key in each server's memory map. Room broadcasts become completely siloed. To scale real-time applications horizontally, servers cannot rely on local process memory as the source of truth for client communication. They require a centralized, high-throughput message bus that sits outside the application layer. To see why in-memory WebSocket architectures fail under horizontal scaling, you don't need a complex cloud cluster. You can reproduce the exact failure on localhost by running two instances of a basic Node.js server on different ports. Initialize an isolated Node.js environment and configure it to use ES Modules: mkdir socket-scaling-demo cd socket-scaling-demo npm init -y npm pkg set type="module" npm install express socket.io socket.io-client server.js Create a server.js file that reads a PORT environment variable, binds Socket.IO, and listens for a generic broadcast event : python import http from "node:http"; import express from "express"; import { Server } from "socket.io"; const app = express ; const server = http.createServer app ; const io = new Server server, { cors: { origin: " " }, } ; const PORT = process.env.PORT || 3001; const INSTANCE NAME = process.env.INSTANCE NAME || Instance-${PORT} ; io.on "connection", socket = { console.log ${INSTANCE NAME} Client connected: ${socket.id} ; // When a client sends a message, attempt to broadcast it to all connected sockets socket.on "broadcast event", data = { console.log ${INSTANCE NAME} Received broadcast request from ${socket.id}: , data, ; // io.emit should theoretically reach everyone io.emit "notification", { origin: INSTANCE NAME, sender: socket.id, payload: data, } ; } ; socket.on "disconnect", = { console.log ${INSTANCE NAME} Client disconnected: ${socket.id} ; } ; } ; server.listen PORT, = { console.log ${INSTANCE NAME} listening on http://localhost:${PORT} ; } ; Open two separate terminal tabs and start two distinct instances representing two worker processes behind a load balancer: PORT=3001 INSTANCE NAME="Server-A" node server.js PORT=3002 INSTANCE NAME="Server-B" node server.js Both instances are now live on your machine, bound to different network ports, and executing in completely segregated memory spaces. test-clients.js Now create a test runner script test-clients.js to simulate two separate users. Client 1 connects to Server-A :3001 . Client 2 connects to Server-B :3002 . js import { io } from "socket.io-client"; // Client 1 lands on Server A const client1 = io "http://localhost:3001", { transports: "websocket" } ; // Client 2 lands on Server B const client2 = io "http://localhost:3002", { transports: "websocket" } ; client1.on "connect", = { console.log Client 1 Connected to Server-A ID: ${client1.id} ; } ; client2.on "connect", = { console.log Client 2 Connected to Server-B ID: ${client2.id} ; } ; // Listen for incoming notifications on both clients client1.on "notification", msg = { console.log Client 1 Received notification: , msg ; } ; client2.on "notification", msg = { console.log Client 2 Received notification: , msg ; } ; // Wait 1 second for handshakes to settle, then emit an event from Client 1 setTimeout = { console.log '\n Client 1 emitting: "broadcast event" - "Task 402 Finished"\n', ; client1.emit "broadcast event", { task: "Task 402 Finished" } ; }, 1000 ; node test-clients.js Look closely at your terminal output: Client 1 Connected to Server-A ID: Wk9vA8j ... Client 2 Connected to Server-B ID: gU4sZ2m ... Client 1 emitting: "broadcast event" - "Task 402 Finished" Client 1 Received notification: { origin: 'Server-A', sender: 'Wk9vA8j ...', payload: { task: 'Task 402 Finished' } } Client 1 receives its own reflected notification from Server-A, but Client 2 receives absolutely nothing. Server-A Client connected: Wk9vA8j ... Server-A Received broadcast request from Wk9vA8j ...: { task: 'Task 402 Finished' } Server-B Client connected: gU4sZ2m ... Complete silence. Server-A did exactly what its code instructed: it queried its internal heap, found all sockets in its local memory pool Wk9vA8j ... , and dispatched the TCP packet. It had no mechanism to notify Server-B that a global event occurred. In a production environment where tens of thousands of users are randomly distributed across 10 container replicas, over 90% of your users will miss every broadcast event. To bridge the gap between isolated Node.js processes, we need a communication channel that operates outside application memory. The channel must be extremely fast—introducing less than a millisecond of overhead—so real-time events don't lag behind. This is where Redis comes in. While developers commonly think of Redis as a key-value cache or a session store, Redis includes a native, lightweight messaging pattern: Publish/Subscribe Pub/Sub . Redis Pub/Sub is a pure fire-and-forget message broker. It does not store messages on disk, track delivery status, or maintain historical logs: Publishers send messages to named channels e.g., PUBLISH channel orders '{"id": 402}' . Subscribers listen on those channels e.g., SUBSCRIBE channel orders . Whenever a message is published, Redis broadcasts a copy of that payload across the network to all connected subscribers in memory simultaneously . Because Redis runs in C and keeps all channel mappings in memory, routing a packet between clients typically takes fractions of a millisecond. Under the hood, Socket.IO relies on an abstraction called an Adapter. io.emit "event", payload ; // or io.to "room-1" .emit "event", payload ; Socket.IO does not execute the network writes directly. It passes the event, target room, and data to its default adapter: the socket.io-adapter . The default adapter's implementation is straightforward: it maintains local JavaScript Map and Set instances containing all connected socket IDs and their associated rooms. It loops through those memory structures, finds the matching TCP sockets attached to that specific Node.js process, and writes the bytes out. If a client isn't in that local Map , the default adapter has no way to find or contact them. @socket.io/redis-adapter Works The official @socket.io/redis-adapter replaces the default in-memory adapter. Instead of confining event delivery to local memory, it turns every Node.js instance into both a Publisher and a Subscriber on Redis. Here is the exact lifecycle of an event when the Redis adapter is active: When you run io.emit 'notification', payload on Instance 1, the Redis adapter intercepts the call. Instead of only iterating over its local sockets, the adapter serializes the event name, packet arguments, and target room/namespace into a binary buffer or JSON payload. It pushes this packet to Redis using an active Redis Publish client: PUBLISH "socket.io / "