A WebSocket that throws an error is easy. You see it, you handle it. The dangerous case is the one that goes quiet: the TCP connection is technically still open, the socket object still reports readyState === OPEN, and no data is arriving. Your application thinks it has a live feed. It has a corpse.
I ran into this building a perpetual futures grid trading system. The bot consumes a live exchange market feed over WebSocket and makes order decisions from it. A dropped feed that the client doesn't notice is not a cosmetic bug โ it means the bot is reacting to a price snapshot that stopped updating minutes ago. Every layer of risk management downstream assumes the feed is current. When the feed lies, the whole system lies.
None of what follows is specific to trading. Any product built on a live feed โ market data, order-status streams, chat, collaborative editing, IoT telemetry, live dashboards โ has the same failure surface. The connection will drop. The question is whether your client notices, and what it does next.
The Four Problems Nobody Solves in the Demo
Every WebSocket tutorial shows you new WebSocket(url) and an onmessage handler. That code works on your laptop for the ten minutes you're testing it. In production โ over weeks, across flaky mobile networks, NAT timeouts, load-balancer idle limits, exchange-side maintenance windows โ four things go wrong that the demo never covers:
- Detecting a dead connection.
onclosedoes not always fire. A silently half-open socket can sit there for minutes. - Reconnecting without a thundering herd. If ten thousand clients all reconnect the instant the server comes back, you knock it over again.
- Restoring state after reconnect. A fresh socket has no subscriptions. The server has forgotten who you are.
- The gap while you were disconnected. Messages published during the outage are gone unless you do something about it.
Solve all four and you have a resilient client. Skip any one and you have a client that works until it doesn't.
Problem 1: Detecting the Dead Connection
The core mistake is trusting readyState. It tells you what the local socket object believes, not whether packets are actually moving. A connection dropped at the network layer โ a NAT table entry that expired, a phone that switched from Wi-Fi to cellular โ can leave the socket in OPEN with nothing coming through.
The fix is an application-level heartbeat. You send a ping on an interval and expect a pong back within a deadline. If the pong doesn't arrive, you declare the connection dead and tear it down yourself, rather than waiting for a TCP timeout that may be minutes away.
Many protocols give you a native ping/pong frame (RFC 6455 defines them), and the Node.js ws library exposes them directly. In the browser, WebSocket gives you no access to control frames, so you send an application-level heartbeat message instead. The logic is the same either way:
// heartbeat.ts
interface HeartbeatOptions {
intervalMs: number; // how often to ping
timeoutMs: number; // how long to wait for a pong before declaring death
onDead: () => void; // called when a pong is missed
}
export function attachHeartbeat(ws: WebSocket, opts: HeartbeatOptions) {
let pingTimer: ReturnType<typeof setInterval> | undefined;
let pongTimer: ReturnType<typeof setTimeout> | undefined;
function schedulePing() {
pingTimer = setInterval(() => {
// Send the ping and start the deadline for its pong.
ws.send(JSON.stringify({ op: "ping", ts: Date.now() }));
pongTimer = setTimeout(() => {
// No pong in time. The socket may still say OPEN โ ignore it.
opts.onDead();
}, opts.timeoutMs);
}, opts.intervalMs);
}
function onPong() {
// Any inbound pong clears the pending death timer.
if (pongTimer) clearTimeout(pongTimer);
}
function stop() {
if (pingTimer) clearInterval(pingTimer);
if (pongTimer) clearTimeout(pongTimer);
}
return { start: schedulePing, onPong, stop };
}Two details matter here.
The heartbeat is a liveness check, not a keepalive. Its job is to notice death quickly, not just to hold the connection open. Set timeoutMs shorter than any upstream idle timeout you know about โ load balancers commonly cut idle connections at 60 seconds, so a 20-second interval with a 10-second timeout catches trouble long before that.
Any inbound traffic is proof of life. If you're receiving a firehose of market data every few milliseconds, a missing pong is nearly impossible โ the data itself proves the connection is alive. The heartbeat earns its keep during quiet periods, when a feed with nothing to say looks identical to a feed that has died. Without an explicit probe, you can't tell "the market is calm" from "the socket is dead."
Problem 2: Reconnecting Without a Thundering Herd
Once you've declared the connection dead, you reconnect. The naive version retries immediately, or on a fixed interval:
// do not do this
ws.onclose = () => {
setTimeout(connect, 1000); // fixed 1s retry
};This is wrong in two ways. First, if the server is down, hammering it every second does nothing but waste CPU and fill logs. Second โ and this is the one that takes down infrastructure โ a fixed interval synchronizes every client. When an exchange or a SaaS backend has a brief outage, every connected client drops at roughly the same moment. If they all retry on the same fixed schedule, they arrive back in lockstep. The server, just recovering, takes the whole fleet at once and falls over again. This is the thundering herd.
The answer is exponential backoff with jitter. Backoff spaces out retries so a genuinely down server gets breathing room. Jitter spreads the fleet across a window so they don't arrive together.
// backoff.ts
interface BackoffOptions {
baseMs: number; // first delay
maxMs: number; // ceiling
factor: number; // growth per attempt, e.g. 2
}
export function computeDelay(attempt: number, opts: BackoffOptions): number {
const { baseMs, maxMs, factor } = opts;
// Exponential growth, capped at maxMs.
const capped = Math.min(maxMs, baseMs * factor ** attempt);
// Full jitter: pick a random point in [0, capped].
// This is what actually breaks the herd โ not the backoff, the randomness.
return Math.random() * capped;
}The jitter is not decoration. Exponential backoff alone still synchronizes clients โ every client that dropped at the same time computes the same capped value and retries together, just with a longer gap. Full jitter, drawing a uniform random delay in [0, capped], is what actually decorrelates them. This is the "Full Jitter" strategy from the well-known AWS Architecture Blog analysis of backoff, and it's the one I default to. In their simulations, Full Jitter substantially cuts both client work and server load versus plain exponential backoff with no jitter, and it minimizes total work (on par with Equal Jitter) โ the trade-off is slightly higher completion time than the Decorrelated Jitter variant, which comes out fastest on that axis. For a reconnecting feed I care more about not hammering the server than about shaving the last few milliseconds, so Full Jitter is the default I reach for.
The reconnection loop ties backoff to an attempt counter that resets on success:
// reconnect.ts
const backoff = { baseMs: 500, maxMs: 30_000, factor: 2 };
let attempt = 0;
let ws: WebSocket | undefined;
function connect() {
ws = new WebSocket(URL);
ws.onopen = () => {
attempt = 0; // success resets the backoff
onConnected(ws!);
};
ws.onclose = () => {
const delay = computeDelay(attempt, backoff);
attempt += 1;
setTimeout(connect, delay);
};
}One guard worth adding in the browser: pause reconnection attempts when the tab is hidden or the device reports offline (navigator.onLine, the offline/online events, visibilitychange). There's no point burning backoff attempts against a network you know is down, and reconnecting the instant online fires is faster than waiting out a backoff timer that started while the machine was asleep.
Problem 3: A Fresh Socket Remembers Nothing
Here's the trap that turns a working reconnect into a silent failure. Your backoff fires, the socket reopens, onopen runs, everything looks healthy โ and no data arrives. A new WebSocket is a blank connection. Whatever channels you subscribed to on the old socket died with it, and the server has no idea you want them again.
Resubscription has to be an explicit, deterministic step in your onopen handler. The client is the source of truth for what it should be subscribed to; the connection is disposable. So you keep the desired subscription set in application state, outside the socket, and replay it every time you connect.
// subscriptions.ts
class FeedClient {
// The desired subscription set. Survives across reconnects because it
// lives on the client, not on the socket.
private desiredChannels = new Set<string>();
private ws?: WebSocket;
subscribe(channel: string) {
this.desiredChannels.add(channel);
// If we're connected right now, send it immediately.
if (this.ws?.readyState === WebSocket.OPEN) {
this.sendSubscribe([channel]);
}
// If not, onopen will replay the whole set โ nothing else to do.
}
unsubscribe(channel: string) {
this.desiredChannels.delete(channel);
if (this.ws?.readyState === WebSocket.OPEN) {
this.send({ op: "unsubscribe", channels: [channel] });
}
}
private handleOpen() {
// Replay the entire desired set on every fresh connection.
if (this.desiredChannels.size > 0) {
this.sendSubscribe([...this.desiredChannels]);
}
}
private sendSubscribe(channels: string[]) {
this.send({ op: "subscribe", channels });
}
private send(msg: unknown) {
this.ws?.send(JSON.stringify(msg));
}
}This same structure covers authentication. If your feed has private channels โ order updates, account events โ the auth handshake is also part of onopen, and it must run before the private resubscriptions. In the grid system, public price feeds are shared across accounts while each account holds its own authenticated private connection, so the two have different reconnection responsibilities: the public socket only needs to replay symbol subscriptions, the private socket has to re-authenticate first and only then resubscribe. Keeping those concerns on separate connections makes the reconnect logic for each one simpler, not harder.
Problem 4: The Gap You Didn't See
The last problem is the subtlest, and whether you even need to solve it depends entirely on your data.
While you were disconnected โ the seconds between the socket dying and your resubscription landing โ the server kept publishing. Those messages are gone. What you do about that gap is a function of what the feed carries:
- Snapshot-style feeds (order books, price tickers, presence state) are self-healing. The next message overwrites the last. You missed some intermediate states, but the moment you reconnect you're current again. For a lot of live dashboards, the correct answer to the gap problem is: do nothing, just reconnect.
- Event-style feeds (order fills, chat messages, audit events, state transitions) are not self-healing. A missed fill is a fill you never processed. Here the gap is a correctness bug, and reconnecting cleanly is not enough โ you have to recover the missed events.
For the self-healing case, many feeds also offer a snapshot on (re)subscribe: you resubscribe, the server sends a full snapshot, and then incremental updates resume from a known point. That's the cleanest recovery there is โ the protocol does the work for you. Take it when it's offered.
For event feeds, you need one of two things from the server side, and no amount of client cleverness invents them if the server doesn't provide them:
- A cursor or sequence number. Each message carries a monotonic sequence id. On reconnect you tell the server "resume from sequence N" and it replays everything after N. This is the reliable option โ it's exactly how you'd catch up a stream without gaps or duplicates.
- A REST catch-up. No replay endpoint, but a REST API you can query for "everything since timestamp T." On reconnect, you fetch the gap over REST, reconcile it against what's live, and only then trust the socket again.
The grid system leans on the second pattern as its backbone: the exchange REST API is the single source of truth on every restart, and the bot reconstructs full state from it rather than trusting whatever the socket happened to deliver. The WebSocket is the fast path; REST is the ground truth you reconcile against. That division is deliberate โ it means a dropped feed degrades into "slower, from REST" instead of "wrong, from a stale socket."
If you do have a sequence number, the client-side check is small but load-bearing: detect the gap by watching for a jump in the sequence.
// sequence-gap.ts
let lastSeq: number | undefined;
function onFeedMessage(msg: { seq: number; payload: unknown }) {
if (lastSeq !== undefined && msg.seq !== lastSeq + 1) {
// A gap. We missed messages (lastSeq+1 .. msg.seq-1).
// Trigger a resync โ REST catch-up or a sequenced replay request โ
// rather than processing this message as if nothing was skipped.
triggerResync(lastSeq + 1, msg.seq - 1);
return;
}
lastSeq = msg.seq;
process(msg.payload);
}The point isn't the four lines of code. It's the decision behind them: an event feed without gap detection will process a corrupt view of the world and never tell you. Deciding, per feed, whether the gap is "harmless, ignore it" or "correctness bug, reconcile it" is the actual engineering. The code just enforces the decision you already made.
Where This Doesn't Apply
Not every WebSocket needs all of this, and building it where you don't need it is its own kind of mistake.
If your feed is a cosmetic live-update on an internal tool that a human is watching, a bare reconnect with a fixed delay is fine. Nobody dies if the notification badge is thirty seconds stale. If your feed is snapshot-style and the platform gives you a snapshot on resubscribe, you can skip the entire replay machinery โ problem 4 solves itself. And if you can tolerate polling, a plain REST poll on an interval is often more robust than a WebSocket, because it has no connection state to lose. WebSockets earn their complexity when you need low latency on a high-frequency feed; below that bar, they're a liability you're choosing to maintain.
The reason to build the full stack โ heartbeat, jittered backoff, deterministic resubscription, gap recovery โ is that your application makes decisions from the feed that are expensive to get wrong. A trading bot acting on a stale price is the sharp version of this. But a logistics dashboard that dispatches on stale location data, or a billing system that acts on a missed subscription event, has the same shape: the cost of a silently dead feed is real money or real damage, not a stale pixel.
That's the line. Match the resilience to what a wrong feed costs you โ no more, no less.
I build live-feed integrations where a dropped connection has real consequences โ market data, order events, device telemetry โ and the client has to survive real network conditions, not just the demo. If you're wiring a WebSocket your product depends on and want it to hold up in production, get in touch. I take on API and integration work and longer engagements.
Further reading:
- RFC 6455 โ The WebSocket Protocol (ping/pong control frames, section 5.5)
- Exponential Backoff and Jitter โ AWS Architecture Blog
- Idempotency Keys for API Retries โ the same "don't process the same event twice" concern, on the request side
- Perpetual Futures Grid Trading System โ the project this feed resilience work came from









