TL;DR — Real-time order tracking is a demo in an hour and a production incident for a month. The root cause of almost every bug is treating the WebSocket as if it were reliable state instead of what it actually is: a best-effort notification channel that drops messages whenever a phone locks, a tunnel swallows a packet, or you deploy. The fixes all reduce to one rule — the socket delivers hints, the database holds truth. Send events to nudge the client to refetch (or make them replayable), authorize every subscription, scope everything to rooms, and add a Redis adapter the moment you run more than one Node process.

I’ve built live order tracking twice. The first time I learned that the happy path is a lie. Here’s the honest list — the mistakes, in the order they hurt.

1. I treated the socket as the source of truth

The first design pushed state over the socket: emit('status', 'preparing'), then emit('status', 'out_for_delivery'), and the client just displayed whatever last arrived. It demoed perfectly. In production, customers’ phones missed the middle event constantly — screen locked, backgrounded, weak signal — and got stuck showing “preparing” for an order already at their door.

A WebSocket does not guarantee delivery. It is not a database with a push API. The moment I stopped sending state and started sending signals — “something changed, here’s the order id” — and had the client refetch the authoritative status over plain HTTP, the “stuck status” class of bug vanished.

// ❌ the socket IS the state — miss the event, miss the truth
socket.emit('order_status', { orderId, status: 'out_for_delivery' });

// ✅ the socket is a hint — truth lives in the DB, fetched over HTTP
socket.emit('order_updated', { orderId });   // "go refetch this one"
// client: on('order_updated', ({orderId}) => refetch(`/orders/${orderId}`))

This is the same lesson as making the payment webhook the source of truth: the unreliable channel is allowed to notify, never to own the fact.

2. I assumed “connected” meant “receiving”

socket.connected === true tells you the transport is up. It tells you nothing about whether the last three events reached the app. A client can be connected and still have missed everything that happened during a 200ms blip that auto-reconnected before you noticed.

So “connected” is not a delivery guarantee. I stopped reasoning about connection state and started reasoning about data freshness: on every (re)connect, the client refetches the current state of anything it’s watching, unconditionally. Connection is a transport detail; correctness comes from resyncing on reconnect.

3. I lost every event that happened while disconnected

The painful one. A delivery goes assigned → picked_up → nearby → delivered over ten minutes. The customer’s phone was locked for six of them. Socket.IO happily reconnects — but the events fired while it was gone are simply gone. No backfill, no replay.

The fix is a re-sync on reconnect, not smarter event handling:

  [ phone locked 6 min ]         reconnect
       │                            │
  ─────┴──── events fire here ──────┴──────▶
       picked_up  nearby  delivered  │
       (all missed by the client)    └─▶ client refetches /orders/:id
                                          → shows 'delivered' correctly
socket.on('connect', () => {
  // We may have missed events while away. Never trust the gap.
  for (const orderId of watchedOrders) refetch(`/orders/${orderId}`);
});

If you genuinely need the sequence (a live event feed, not just current status), the socket alone can’t give it to you — you need a durable event log the client can ask “give me everything after event #N,” which is the outbox/reconciliation idea pointed at a UI. For status tracking, refetch-on-reconnect is enough and far simpler.

4. I re-emitted confirmations wrong (or not at all)

Related trap: a customer reconnects right as their order is confirmed, and the order_confirmed event fired during the gap. Without a resync they’d sit on a spinner forever for an order that’s already done.

The server has to be willing to re-answer “what’s the state of this?” on demand, and re-emitting a confirmation must be idempotent on the client — showing “confirmed” twice is a no-op, not a duplicate toast. Assume every notification can arrive zero times or many times, and design the client to converge either way.

5. I broadcast to everyone instead of scoping to rooms

Early on, io.emit(...) sent order updates to every connected client, and each client filtered for “is this mine?” in the browser. That’s a data leak (every client receives every customer’s order events) and a performance cliff (N clients × M events).

Rooms fix both. Each connection joins only the rooms it’s entitled to, and the server emits to the room:

io.on('connection', (socket) => {
  const { userId } = socket.data.auth;       // see #6
  socket.join(`customer:${userId}`);         // this user's own updates only
});

// elsewhere: notify exactly the one customer
io.to(`customer:${order.customerId}`).emit('order_updated', { orderId: order.id });

The server decides who’s in which room. The browser never gets data it shouldn’t see, because it’s never sent it.

6. I authenticated the HTTP API but not the socket

The REST API had auth middleware. The socket connection… didn’t, at first. Anyone could open a socket and join('customer:12345') for an id that wasn’t theirs and receive that customer’s order stream. A socket is a public entry point exactly like an HTTP route, and it needs the same gate.

Authenticate during the handshake using a connection middleware, before any room joins, and derive room names from the verified identity — never from client-supplied arguments:

io.use((socket, next) => {
  const user = verifyToken(socket.handshake.auth.token);   // reject if invalid
  if (!user) return next(new Error('unauthorized'));
  socket.data.auth = user;                                  // trusted from here on
  next();
});

Rule that generalizes: the client may ask to join a room; the server decides whether it’s allowed, from server-verified identity. Same server-authoritative posture as everywhere else in the system.

7. I added a second Node instance and half the events vanished

Everything worked on one process. Then I scaled to two (or PM2 cluster mode, or a second pod), and tracking got flaky in a way that made no sense — until I understood why. Socket.IO rooms live in the memory of one process. Customer A is connected to instance 1; the event that should reach them is emitted from instance 2, which has no idea A exists. The emit goes nowhere.

The fix is the Redis adapter, which lets instances publish room events to each other:

import { createAdapter } from '@socket.io/redis-adapter';
io.adapter(createAdapter(pubClient, subClient));
// now io.to('customer:12').emit(...) reaches customer 12 on ANY instance

If you run more than one process — and any real deployment does, per zero-downtime deployments — you need this from the start. It’s the single biggest “works locally, breaks in prod” gap with Socket.IO.

8. I let sticky-session assumptions bite me

Socket.IO’s HTTP long-polling fallback needs requests from one client to hit the same instance during the handshake, or the upgrade fails intermittently. Behind a load balancer without sticky sessions (or session affinity), you get mysterious connection errors that only some users, sometimes, experience. Either enable sticky sessions at the balancer or force the WebSocket transport where you can — but know which you’ve done, because the default will find the gap for you.

9. I never load-tested the concurrent-connection ceiling

Order tracking’s load profile is brutal and specific: connections aren’t spread evenly — they spike with orders. On a food platform that means the lunch rush, when thousands of live-tracking sockets open inside the same 90-minute window (the same peak that stresses everything else in the system). File-descriptor limits, event-loop saturation from too many concurrent emits, and memory per connection are all real ceilings, and none of them show up with five test tabs open. Simulate thousands of concurrent connections before launch, not after.

What I’d still improve

  • A durable, replayable event feed. Refetch-on-reconnect gives correct current state but loses history. For a true live timeline I’d persist status transitions and let the client request “everything after event N,” turning the socket into a low-latency accelerator over a durable log rather than the delivery mechanism itself.
  • Presence and delivery acks. I’d add lightweight client acknowledgements for the few events that genuinely must land (delivery completed), so the server can retry or fall back to push notification when a socket silently black-holes.
  • Backpressure on fan-out. During peak, a burst of emits to huge rooms can stall the event loop. I’d batch and rate-limit non-critical updates (driver location every 3s, not every 200ms) rather than emitting on every tick.

FAQ

Should a WebSocket carry state or just notifications? Notifications. A socket is a best-effort channel with no delivery guarantee, so a client that misses one message shows the wrong state indefinitely. Emit “this record changed, here is its id” and let the client refetch authoritative state over HTTP.

Does socket.connected mean the client received my events? No. It tells you the transport is up right now and nothing about what arrived. A client can reconnect after a brief blip having missed everything in the gap, and still report as connected. Reason about data freshness, not connection state.

How do you handle events that fired while a client was disconnected? Refetch on reconnect. Socket.IO reconnects automatically but does not replay what it missed, so the client should re-pull the current state of everything it is watching on every connect. If you genuinely need the sequence, you need a durable event log, not a socket.

Why scope socket events to rooms instead of broadcasting? Broadcasting sends every client every other client’s events, which is both a data leak and a cost that grows with connections times events. Rooms let the server decide who receives what, so data a client should not see is never sent rather than filtered in the browser.

Do WebSocket connections need their own authentication? Yes. A socket is a public entry point exactly like an HTTP route. Authenticate during the handshake before any room joins, and derive room names from the verified identity — never from arguments the client supplies.

What breaks when you add a second Node process? Events emitted by one process do not reach clients connected to the other, so roughly half your notifications vanish. You need an adapter that passes messages between processes, and you need it before you scale out, not after users report missing updates.


The one idea to take away

A WebSocket is a best-effort notification channel, not a database with a push API. Every real-time bug I hit came from forgetting that. Send hints, not truth; resync from the authoritative store on every reconnect; authorize and scope every subscription server-side; and add the Redis adapter the moment you run more than one process. Do that, and “real-time” becomes a fast path over a correct system — instead of a fragile system that’s only correct when the network behaves.