The WebSocket demo is one of the most satisfying five minutes in web development. You open a connection, push a message, watch it appear instantly on another screen. It feels like magic & it makes real-time look easy.
Then you ship it to real users & you discover the demo was lying to you. Not about the happy path — that part's genuine — but about everything around it. I've built a couple of event-driven real-time systems now, including one that syncs physical devices to internal services & almost all the actual engineering lived in the parts the demo skips.
First, did you even need a socket?
Before the costs, the honest question: a lot of "real-time" features don't need a persistent connection at all. If updates flow mostly one direction — server to client — Server-Sent Events are simpler, ride ordinary HTTP & reconnect on their own. If the data changes every thirty seconds and nobody will notice a small delay, polling is fine and you'll sleep better.
I reach for WebSockets when the traffic is genuinely bidirectional and latency actually matters — live collaboration, instant status changes, a device pushing events up while the server pushes commands down. If you can't point at that need, the rest of this post is a list of costs you're volunteering for.
Reconnection is your problem, not the browser's
Here's the first thing the demo hides: connections drop. Laptops sleep, phones change networks, WiFi hiccups, load balancers recycle. The browser will not quietly fix this for you. A dropped socket stays dropped until your code notices and does something about it.
So you write reconnection logic & then you learn why it's harder than a retry loop. If every client reconnects the instant it drops, a brief server blip becomes a stampede — thousands of clients hammering the door in the same second, turning a hiccup into an outage. You need backoff with some jitter so clients come back staggered, not in lockstep.
function connectWithBackoff(url: string) {
let attempt = 0;
const open = () => {
const ws = new WebSocket(url);
ws.onopen = () => (attempt = 0);
ws.onclose = () => {
const base = Math.min(1000 * 2 ** attempt, 30_000);
const jitter = Math.random() * base; // spread the herd out
attempt++;
setTimeout(open, base + jitter);
};
};
open();
}And reconnecting isn't just reopening the socket — it's re-establishing state. What did this client miss while it was gone? Which rooms was it in? A reconnect that reopens the pipe but forgets the context is a bug that only shows up in the field.
Authenticating something that outlives its token
HTTP auth is a per-request affair — every request carries a token and you check it. A WebSocket is one long-lived connection that you authenticate once, at the handshake & then it just... stays open. Possibly for hours. Possibly long after the token you accepted has expired.
So you have to decide what expiry means for an already-open connection. Do you drop it and force a reconnect? Do you let the client refresh a token over the socket itself and re-validate in place? There's no single right answer, but there is a wrong one: authenticating at the handshake and never thinking about it again, so a revoked session keeps streaming data because nobody told the socket the user was logged out.
The gap problem: events fired while nobody was listening
This is the one that bites hardest, because it's invisible in every demo. A client disconnects for eight seconds. During those eight seconds, three events fire. The client reconnects. What happens to those three events?
If your answer is "the server pushed them into a void," you have a correctness bug, not a UX nit — the client's view is now silently wrong and will stay wrong until something else forces a refresh. There are two honest ways out. You can guarantee at-least-once delivery: buffer per-client events and replay the gap on reconnect. Or — usually simpler and more robust — you treat the socket as a nudge, not a source of truth & have the client re-fetch authoritative state on every reconnect.
I lean on the second approach far more often than the first. "On reconnect, ask the server for the current truth" sidesteps a whole category of buffering and ordering bugs. The socket tells you something changed, come look; the actual state comes from a plain request you already know how to make correct.
The wall you hit at server number two
Everything above can work beautifully on a single server, because a single server can hold every connection in memory and know exactly who's connected. The moment you run a second instance for capacity or redundancy, that assumption shatters.
User A is connected to instance 1. User B is connected to instance 2. An event for B originates on instance 1. Instance 1 doesn't have B's socket — it's on the other machine. In-memory connection state, the thing that made everything simple, is now actively wrong.
The standard fix is to stop keeping the routing in any one process's memory and put a shared backbone between the instances — a pub/sub layer (Redis is the common choice) that every instance publishes to and subscribes from. Instance 1 publishes "event for B," every instance hears it & whichever one holds B's socket delivers it. It's not exotic, but it's a real architectural piece you have to add & it's better to know that before you scale than to discover it during the incident where half your users stopped getting updates.
Where the device work made all of this concrete
Bridging physical devices to a backend takes every one of these problems and turns up the difficulty. The network between a device and your server is genuinely hostile: it drops, it lags, it delivers the same message twice & clocks on the two ends drift apart. You cannot assume a message arrives, arrives once, or arrives in order.
The mindset that survives contact with that environment is to design for messages arriving late, twice, or never. Make handlers idempotent so a duplicate is harmless. Carry timestamps and don't trust that "received second" means "happened second." Treat every delivery as best-effort and reconcile against authoritative state rather than trusting the stream. None of that is exotic once you've been burned — but the demo will never teach it to you, because the demo runs on localhost where the network is perfect and nothing ever drops.
Real-time is worth it when you actually need it. Just go in knowing that the five-minute magic trick is the first five minutes of a much longer, more interesting problem.