A clinician pinged me on a Tuesday to say the dashboard felt “laggy.” It wasn't, really. The page was fine. The problem was on our side, and it was quietly cooking the database.
How we got there
The dashboard needed to show new lab results and appointments as they came in. The first version did the obvious thing: every open page called GET /updates every three seconds. Honestly that's fine when ten people are using it. Nobody notices.
Then more wards came online. And here's the thing about polling that you don't feel until it's too late: the traffic scales with the number of clients, not with how often anything actually changes. A few hundred clinicians, each polling every three seconds, is thousands of requests a minute. The vast majority of them got the same answer back: nothing new. We were paying full price to hit the database and check a timestamp, over and over, mostly to hear “no.”
Push instead of pull
The fix sounds obvious once you say it out loud. Stop asking. Get told. A WebSocket holds one connection open, and when something actually changes, the server pushes it down. Now traffic scales with events, not clients. A quiet ward sends almost nothing across the wire. A busy one sends exactly as much as it needs to.
The part that actually bit me
Here's what the “add WebSockets” tutorials skip. We don't run one API server, we run several behind a load balancer. A clinician's socket lives on instance A. But the write that should notify them, say a lab result landing, might be handled by instance B. Instance B has no idea that A is holding that socket. So the push goes nowhere and the user sees nothing.
Redis Pub/Sub is the glue. On any write worth broadcasting, the handling instance publishes to a channel. Every instance subscribes to that channel at startup. So when B publishes “patient 4123 changed,” A hears about it and pushes to the sockets it actually owns.
// whichever instance handled the write:
await redis.publish("patient-updates", JSON.stringify({ patientId, kind }));
// every instance, once at startup:
sub.subscribe("patient-updates");
sub.on("message", (_channel, raw) => {
const evt = JSON.parse(raw);
for (const ws of socketsFor(evt.patientId)) {
ws.send(raw); // only the sockets this instance owns
}
});That's the whole trick. Redis fans the event out to every instance; each instance only talks to its own connections. Nobody has to know where any given socket lives.
What it bought us
Backend traffic dropped by about 80%. The database CPU graph, which used to look like an EKG, went flat. And the dashboard genuinely felt instant, because an update now arrives the moment it happens instead of up to three seconds later. We removed load and made the product better at the same time, which doesn't happen often.
The honest catch
WebSockets are not free. You take on reconnection logic, auth at connect time, either sticky sessions or a shared registry, and a whole socket layer you now have to scale and watch. If a ten-second poll is genuinely good enough for what you're building, use the poll and go home. We didn't switch because push is fashionable. We switched because the polling was measurably hurting us, and the graph proved it.
Polling is what you build when you don't know what changed. The real fix is to know.