Skip to content
All articles

Real-Time Without the Mess: What a WebSocket Actually Costs You

6 min read

Opening a WebSocket takes one line. Everything after that line is the actual work, and most of it is about a single awkward fact: the connection will drop, and when it comes back your user has a screen full of state that stopped being true at some point you can't identify.

I maintain a dashboard that surfaces live order and status changes over REST and WebSocket, and nearly everything I've learned reduces to being honest about that gap.

First: check you need one

A WebSocket is a stateful, long-lived connection in a stack that is otherwise stateless. That's a real architectural cost — it constrains how you deploy, how you scale, and how you debug — so it should buy something proportionate.

Polling gets dismissed too quickly. A request every ten seconds against a cheap endpoint is stateless, survives a deploy without anyone noticing, works through every proxy ever built, and is trivially debuggable in a network tab. If your "real time" requirement is a badge count that could be a few seconds stale, that's not a real-time requirement — it's a refresh interval.

Server-Sent Events are also worth a look and rarely get one. If the traffic is genuinely one-directional — server pushes, client listens — SSE is a plain HTTP response that streams, with reconnection built into the browser and none of the upgrade-handshake complications. WebSocket earns its place when you need bidirectional, low-latency messaging. Collaborative editing, live cursors, chat. Not "the order list should update."

Reconnecting is not the same as recovering

This is the mistake that produces the worst class of bug, because it produces no error at all.

The socket drops — laptop lid, tunnel, wifi handover, an idle proxy trimming the connection. Your client reconnects a few seconds later and reports itself healthy. But events that fired during those seconds were sent to a socket nobody was holding. They are gone. The UI is now confidently displaying stale data with no indication anything is wrong, which is strictly worse than an error, because an error would at least prompt a refresh.

So a reconnect has to be followed by a resynchronisation, not just a subscription. Either refetch the current state outright, or have the client track a cursor — a sequence number or timestamp of the last event it processed — and ask the server for everything since. The second is nicer and requires the server to actually retain a replayable log, which is a bigger commitment than it first appears. Refetching on reconnect is unglamorous and almost always correct.

Reconnection itself needs exponential backoff with jitter. Without backoff, a server restart means every client retries immediately and in lockstep, and your recovering service gets a synchronised stampede at the worst possible moment. Without jitter, backoff just delays the stampede.

Send the invalidation, not the object

This is the opinion I hold most strongly, and it's the one that keeps the whole thing manageable.

The tempting design is to push the changed entity down the socket and patch it into client state. It feels efficient. What it actually does is create a second, parallel way for data to enter your application — one that skips the fetch layer, skips the validation you do there, skips the permission filtering the REST endpoint applies, and has to be kept in sync with the shape the REST endpoint returns forever.

The alternative is to treat the socket as a notification channel with almost no payload: order 4821 changed. The client invalidates that key and refetches through the same path it always uses. One code path for data, and the socket carries signals rather than state.

You pay a round trip. In exchange, a dropped message degrades into "the UI is briefly stale" instead of "the UI is now permanently wrong", the permission model lives in one place, and you can change the response shape without auditing two producers. If you're using a query cache on the client, this maps directly onto invalidating a key — which is why that pattern feels so natural once you try it.

Genuine low-latency streams — cursors, presence, live pricing at speed — are the exception, where the round trip is the thing you were trying to avoid.

Assume messages arrive twice, out of order, or not at all

TCP guarantees ordering within one connection. It guarantees nothing across a reconnect, and nothing about whether your server actually managed to deliver something before the socket closed. So the same discipline that applies to payment webhooks applies here: give events an id, make handling them idempotent, and don't build anything on "this will arrive exactly once."

Client-side, this is much easier if events describe facts rather than deltas. "Order 4821 is now shipped" can be applied twice with no harm. "Increment the pending count" cannot, and a duplicate makes your badge permanently wrong in a way no refresh path will catch, because nothing knows it's wrong.

The connection outlives the token

Authentication happens once, at the handshake. The connection then stays open for hours. If your access tokens expire in fifteen minutes, you have a socket authorised by a credential that stopped being valid long ago — and, worse, one that keeps streaming data to a session that may have been revoked. Someone logs out on another device, or an admin removes their access, and the socket carries on regardless.

Handle it explicitly: re-authenticate over the connection periodically, or close it and let the client reconnect with a fresh token. And make sure that permission changes invalidate live connections, not just future requests. This is easy to miss precisely because nothing breaks — the data just keeps flowing to someone who should no longer be receiving it.

Connections are state, and state complicates deploys

Every open socket is pinned to one process. That has consequences the REST parts of the app never made you think about.

Deploying drops every connection at once, so backoff and jitter aren't optional niceties. Running more than one instance means an event raised on instance A has to reach subscribers held on instance B, which is what pushes people toward Redis pub/sub or a message broker — and that's the real scaling cost, not the socket count. Load balancers need idle timeouts longer than your heartbeat interval, or they'll quietly cut connections that are perfectly healthy but haven't spoken recently. Sticky sessions help and are their own kind of trap.

Heartbeats matter more than they sound like they should. A TCP connection can be dead for a long time without either side noticing; a periodic ping with a response deadline is how you find out in seconds rather than minutes.

Where I'd start

REST for state, WebSocket for "something changed, go and look." Refetch on reconnect rather than trying to replay. Backoff with jitter. Idempotent handlers keyed on an event id. Show the user when the connection is down, because a stale screen that admits it is far better than one that doesn't.

That's a small enough surface to reason about, and it degrades in the right direction: when the real-time layer fails, you're left with an application that still works and just updates a bit later. Which is roughly the definition of a real-time feature you can leave running unattended.