Jun 30, 2026 · 6 min read
WebSockets in production: what breaks after the demo works
Every WebSocket tutorial follows the same arc: a few lines of server code, a client that connects and echoes messages, and it works on the first try. That demo has taught an entire generation of developers that WebSockets are simple. They are, until you put a load balancer, more than one server process, and real users with real, flaky mobile connections in front of them. That's where the actual engineering starts, and it's almost never covered in the getting-started guide.
Auth doesn't travel over a WebSocket the way you'd expect
A normal REST request carries an Authorization header without a second thought. The WebSocket handshake is an HTTP Upgrade request, so headers technically exist — but browser WebSocket clients don't let you set custom headers on that handshake the way you can on fetch. That pushes people toward putting a token in the connection URL's query string, which quietly leaks that token into server access logs, browser history, and any proxy sitting in the path.
The safer shape: issue a short-lived, single-use connection ticket over a normal authenticated REST call, then hand that ticket — not the real session token — to the WebSocket connection to redeem once.
// REST endpoint, authenticated normally
app.post('/ws-ticket', requireAuth, (req, res) => {
const ticket = createTicket({ userId: req.user.id, ttlSeconds: 30 });
res.json({ ticket });
});
// client connects with the short-lived ticket, not the real token
const socket = new WebSocket(`wss://api.example.com/ws?ticket=${ticket}`);A ticket that expires in thirty seconds and can only be redeemed once is a much smaller thing to have sitting in a log file than a long-lived auth token.
The first thing that breaks when you scale past one process
The tutorial's server keeps connected sockets in a plain in-memory map. That's fine for a single process and falls apart the moment you run two. A user connected to instance A can't receive a broadcast triggered by a request that happens to land on instance B, because instance B has no idea instance A is holding that user's socket.
There are two real fixes, and they trade off differently:
Sticky sessions at the load balancer route a given client to the same backend instance for the life of the connection. Simple to set up, but it concentrates load unevenly — one instance can end up holding a disproportionate share of long-lived connections — and it fights against elastic autoscaling, since draining a "sticky" instance means forcibly dropping every connection pinned to it.
A pub/sub backplane — Redis pub/sub is the common choice — decouples "which process holds the socket" from "which process knows an event happened." Every instance subscribes to the same channel; any instance can publish an event, and every instance checks whether any of its own connected sockets care about that event.
// any instance, on some application event
redisPublisher.publish('order.updated', JSON.stringify({ orderId, status }));
// every instance is subscribed and forwards to its own local sockets
redisSubscriber.on('message', (channel, message) => {
const event = JSON.parse(message);
for (const socket of localSocketsInterestedIn(event.orderId)) {
socket.send(message);
}
});This is the change that actually matters once you're behind more than one server: broadcasting is no longer "loop over my sockets," it's "publish, and let whichever instance owns the relevant socket handle delivery."
The reconnection gap nobody's client code handles correctly
A WebSocket doesn't always close cleanly. A phone goes to sleep, WiFi hands off to cellular, a proxy silently drops an idle connection — the client can sit there believing it's still connected for a while before anything notices. Naive client code that only reconnects on an explicit close event misses exactly this case.
The fix is a heartbeat the application controls, not just the transport's own ping/pong frames — some proxies and CDNs don't forward WebSocket control frames reliably, so an app-level heartbeat message is more portable:
let missedPongs = 0;
setInterval(() => {
if (missedPongs >= 2) {
socket.close(); // force it — triggers reconnect logic
return;
}
socket.send(JSON.stringify({ type: 'ping' }));
missedPongs++;
}, 15_000);
socket.on('message', (raw) => {
const msg = JSON.parse(raw);
if (msg.type === 'pong') missedPongs = 0;
});And reconnection itself needs exponential backoff with jitter, not a fixed retry interval — a fixed interval means every client that dropped during a brief server hiccup reconnects at exactly the same moment, which is how a minor blip turns into a thundering-herd reconnection storm that looks like an outage.
Reconnecting isn't the whole fix — you also lost whatever happened while disconnected
This is the gap that's easy to miss even after reconnection logic is solid: the client comes back online with a fresh socket, but has no idea what messages it missed during the gap, because plain WebSocket delivery gives you no replay and no guarantee. Whether that matters depends entirely on what the socket is carrying — a live chat losing one message is often acceptable; a live order-status feed silently skipping a "cancelled" event is not.
The reliable pattern is to treat the socket as a notification that something changed, and resync the authoritative state over a normal REST call on reconnect, rather than trusting the socket stream to have been complete:
socket.on('open', async () => {
const latest = await fetch('/api/orders/current-state').then((r) => r.json());
applyAuthoritativeState(latest);
// now safe to trust incremental socket events again, until the next drop
});The socket makes the UI feel instant. The REST resync on reconnect is what makes it correct.
Backpressure: broadcasting isn't free at scale
Serializing a payload once and sending that same buffer to ten thousand sockets is cheap. Serializing it separately, per socket, inside a naive sockets.forEach(s => s.send(buildPayload())) loop is not — and it's an easy mistake to make when the code that looked fine with fifty test users starts saturating the event loop with ten thousand real ones.
// serialize once, reuse the same buffer for every socket
const payload = JSON.stringify(event);
for (const socket of interestedSockets) {
socket.send(payload);
}It's a small change with an outsized effect, precisely because it's invisible at low connection counts and becomes the entire bottleneck at high ones.
The pattern underneath all of it
Almost none of the real WebSocket incidents I've dealt with were about the WebSocket protocol itself misbehaving. They were about state living in the wrong place — one process's memory instead of a shared backplane — or about client code trusting a network connection to be more reliable than a network connection has ever actually been. The protocol is the easy five percent. Where the state lives, and what you assume about a connection that looks fine but silently isn't, is the other ninety-five.