Real-Time Order Tracking with Socket.io at Scale
Why push instead of poll
Polling an endpoint every few seconds to check "has my order status changed" scales linearly with active users and is mostly wasted — the overwhelming majority of polls return "nothing changed." A websocket connection lets the server push a status change the instant it happens, replacing thousands of mostly-empty polling requests per minute with one persistent connection per active user and a message only when there's actually something to say.
The problem that shows up the moment you scale horizontally
A single Node.js process holding websocket connections works fine with one server. The moment you run two or three instances behind a load balancer, a client connected to server A won't receive an event emitted from server B — because Socket.io's default in-memory event handling is scoped to a single process. An order status update processed by whichever server happened to handle that API request needs to reach a client that might be connected to a completely different server.
The fix: a shared adapter
const { createAdapter } = require("@socket.io/redis-adapter");
const { createClient } = require("redis");
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();
await Promise.all([pubClient.connect(), subClient.connect()]);
io.adapter(createAdapter(pubClient, subClient));
With the Redis adapter in place, io.to(userRoom).emit(...) broadcasts through Redis pub/sub to every server instance, and whichever one is actually holding that user's connection delivers it. This single change is what makes horizontal scaling of a websocket layer actually work — without it, "add another server" silently breaks real-time delivery for roughly half your users depending on which instance they land on.
Rooms, not broadcast-to-everyone
Emitting every order update to every connected client and filtering client-side is a real anti-pattern — it means every browser tab receives every other customer's order events, wasting bandwidth and creating an unnecessary information leak. Each authenticated connection joins a room scoped to that user (or that order) on connect, and updates are emitted only to the relevant room:
io.on("connection", (socket) => {
const userId = authenticateSocket(socket);
socket.join(`user:${userId}`);
});
// Elsewhere, when an order updates:
io.to(`user:${order.userId}`).emit("order:updated", { orderId, status });
Graceful degradation matters more than the happy path
Corporate networks, some mobile carriers, and aggressive proxies block or degrade websocket connections more often than you'd expect. Socket.io's transport fallback to long-polling handles most of this automatically, but it's worth explicitly testing — and worth making sure the UI doesn't assume real-time delivery is guaranteed. A "last updated" timestamp with a manual refresh option is a reasonable fallback for the small percentage of connections that never establish a proper websocket.
What actually mattered in production
The Redis adapter for horizontal scaling and room-scoped emission for both correctness and bandwidth were the two decisions that made the difference between a real-time feature that worked in a demo and one that held up under actual concurrent user load across multiple server instances.