Iurii RoguliaIurii Rogulia
AboutServicesPricingProjectsStackReviewsPhrasesBlog
Contact
Iuriiย ships.

Iurii Rogulia, IT partner for business & fractional CTO. Professionally building software since 2001.

Think of a number
PricingQuality checklistPrivacy PolicyCookie Policy

Business

TMI Iurii Rogulia
VAT ID: FI29845875
DUNS: 368664211
Lappeenranta, Finland ๐Ÿ‡ซ๐Ÿ‡ฎ

[email protected]
  1. Home
  2. /
  3. Blog
  4. /
  5. WebSocket Reconnection Done Right: Backoff, Jitter, and Replay

Iuriiย Connects: WebSocket Reconnection Done Right: Backoff, Jitter, and Replay

A live feed that silently dies is worse than one that throws. Here's how to detect dead sockets, reconnect without a thundering herd, and replay what you missed.

August 14, 2026ยท 13 min read

WebSocket reconnection for production: heartbeat dead-connection detection, exponential backoff with jitter, resubscription, and message replay.

Stack

TypeScriptNode.js

Topics

WebSocketsAPIReal-timeBackend
WebSocket Reconnection Done Right: Backoff, Jitter, and Replay

On this page

  • The Four Problems Nobody Solves in the Demo
  • Problem 1: Detecting the Dead Connection
  • Problem 2: Reconnecting Without a Thundering Herd
  • Problem 3: A Fresh Socket Remembers Nothing
  • Problem 4: The Gap You Didn't See
  • Where This Doesn't Apply

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:

  1. Detecting a dead connection. onclose does not always fire. A silently half-open socket can sit there for minutes.
  2. Reconnecting without a thundering herd. If ten thousand clients all reconnect the instant the server comes back, you knock it over again.
  3. Restoring state after reconnect. A fresh socket has no subscriptions. The server has forgotten who you are.
  4. 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:

  1. 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.
  2. 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
Iurii Rogulia

Working on something like this?

API & Integrations

Wiring a live data feed your product depends on โ€” market data, order events, device telemetry? A WebSocket client that survives real network conditions is the kind of resilience layer I build in from the start.

More about this service

Relevant client work

View all projects
vatnode โ€” EU VAT Validation API
vatnode โ€” EU VAT Validation API
January 19, 2026
vatnode โ€” EU VAT Validation API

Developer-first SaaS API for EU VAT validation via VIES with Redis caching, change monitoring, and webhook notifications.

n8n Verified Community Node โ€” EU VAT Validation in Workflows
n8n Verified Community Node โ€” EU VAT Validation in Workflows
August 6, 2026
n8n Verified Community Node โ€” EU VAT Validation in Workflows

Verified n8n node that puts VIES VAT validation and EU rate lookups inside a workflow, so onboarding or invoicing can decide on a real VAT number instead of a

vatnode-mcp โ€” Official MCP Server for EU VAT Validation
vatnode-mcp โ€” Official MCP Server for EU VAT Validation
May 20, 2026
vatnode-mcp โ€” Official MCP Server for EU VAT Validation

Open-source MCP server that lets Claude Desktop, Cursor and other MCP clients validate EU VAT numbers and look up rates directly in chat.

What clients say

โ€œ

I'd built most of our MVP with Cursor and it looked finished โ€” it compiled, the tests were green, the demo worked. It just wouldn't survive real users. Iurii took it over and found what I couldn't see: authentication handled four different ways, tests that only asserted what the code already did, and a dependency list half of which was unused. He didn't rewrite it from scratch โ€” he told me honestly what was salvageable, ripped out the dead code, and got it to something a real team could build on. Two weeks and it went from 'looks done' to actually shippable.

Sebastian Falk ๐Ÿ‡ธ๐Ÿ‡ช

Founder

Stack

Next.jsTypeScript

Topics

Technical DebtAICode ReviewArchitecture
โ€œ

Our sales team was copying leads from the website into HubSpot by hand and things were falling through the cracks. Iurii built a proper integration that pushes every form submission straight into the CRM with the right owner and tags, and set up webhooks so status changes flow back to us automatically. He handled the edge cases I hadn't even thought about โ€” duplicate contacts, API rate limits, retries when HubSpot is briefly down. It just works, which is exactly what I wanted.

Ingrid Solberg ๐Ÿ‡ณ๐Ÿ‡ด

Operations Manager

Services

HubSpot

Topics

APIWebhooksCRMIntegration
โ€œ

We wanted to add an AI feature that turns messy user notes into structured records, but our first attempt returned unpredictable JSON that broke the app half the time. Iurii rebuilt it using structured outputs against the OpenAI API with proper validation, so the data is always shaped the way our database expects. He also added a fallback path for when the model is unsure instead of letting it guess. It's been running in production for a month with no manual cleanup.

Bram de Vries ๐Ÿ‡ณ๐Ÿ‡ฑ

Product Lead

Stack

Next.jsTypeScript

Services

OpenAI

Topics

AILLMStructured Outputs

Related articles

Idempotency Keys: Building Retries That Don't Double-Charge
July 1, 2026ยท 8 min
Idempotency Keys: Building Retries That Don't Double-Charge

Idempotency key API design: make POST retries that don't double-charge โ€” Postgres key table, ON CONFLICT claim, response replay, TTL, edge cases.

Stack

TypeScriptNode.js

Databases

PostgreSQL

Topics

APISaaSBackend
API Key Management for a Public SaaS API
August 5, 2026ยท 13 min
API Key Management for a Public SaaS API

API key management for a public SaaS: hashing keys at rest, prefix + last-4 display, fail-closed validation, and revocation โ€” plus the scoping, rotation, and

Stack

TypeScriptNode.jsHono

Libraries

Drizzle ORM

Databases

PostgreSQL

Topics

SaaSAPIAuthSecurity
Webhook Security Beyond the Signature Check
July 17, 2026ยท 14 min
Webhook Security Beyond the Signature Check

Webhook security past the signature: replay attacks, payload trust, DoS, IP allow-listing, and secret rotation โ€” what verification does not protect.

Stack

Next.jsTypeScriptNode.js

Topics

APIWebhooksSecurityWeb Development
Self-Hosting Node.js API: Caddy, Docker Compose, VPS
April 3, 2026ยท 11 min
Self-Hosting Node.js API: Caddy, Docker Compose, VPS

Self-host a Node.js API on a โ‚ฌ6/month VPS: Caddy reverse proxy, Docker Compose, zero-downtime deploy script, and GitHub Actions CI โ€” complete production setup.

Stack

TypeScriptNode.jsHonoDockerCaddy

Services

Vercel

Topics

InfrastructureSaaSAPI
Redis Rate Limiting in Node.js: Sliding Window Algorithm
October 10, 2025ยท 15 min
Redis Rate Limiting in Node.js: Sliding Window Algorithm

Redis rate limiting for APIs: sliding window ZADD with Lua atomicity, in-memory fallback, multi-tier limits by IP and API key, and developer-friendly response

Stack

TypeScriptNode.jsHono

Libraries

ioredis

Databases

Redis

Topics

SaaSAPIPerformanceArchitecture