Tutorial

WebSocket reconnect: heartbeats, backoff and close codes in production

Reconnecting is one line of code. Reconnecting without a thundering herd, a silent half-open socket or a client that falls further behind every minute takes about a hundred.

On this page
  1. Why long-lived connections drop
  2. WebSocket close codes you will actually see
  3. Backoff with jitter
  4. WebSocket heartbeat: detect the connections that never close
  5. A WebSocket client in Node.js that reconnects
  6. The same logic in the browser
  7. Slow consumers and WebSocket compression
  8. WebSocket reconnect without a resubscribe storm
  9. Questions

Key takeaways

  • Reconnect after every close except an authentication or plan refusal (HTTP 401 or 403 on the upgrade), and resubscribe everything once the new `ready` frame arrives.
  • Use exponential backoff with jitter capped around 30 seconds, and reset it only after a connection has stayed healthy for a while.
  • Close 1006 means the connection died without a close frame, 1012 is a planned server release, and 4008 means your client stopped answering heartbeats.
  • Detect half-open sockets yourself: send a JSON ping on a timer and tear the socket down when nothing at all has arrived for too long.
  • Turn per-message compression off and keep the message handler fast; a client that falls behind ends up disconnected.

A production WebSocket reconnect loop does five things: it notices the connection is gone (including the cases where nothing tells it), waits a jittered, growing delay, opens a fresh socket, waits for the server's ready signal, and resubscribes everything. Most of the bugs live in the first two. This tutorial builds that loop for Node.js and the browser against a live market-data feed, and runs it through a real disconnect.

The protocol details (the ready frame, subscribe messages, snapshots) are covered in the stock WebSocket API guide. Here we focus on what happens when the connection breaks, which on a long-lived market-data socket is a matter of when, not if.

Why long-lived connections drop

  • The networkWi-Fi switches, mobile handovers, NAT and load-balancer idle timeouts, laptops that sleep. Usually close 1006, sometimes no close at all.
  • Server releasesA deploy replaces the serving process. The stream announces it with a SERVER_RESTART frame and closes with 1012.
  • Your own event loopBlock it long enough and your client misses the server's heartbeats. The server closes with 4008.
  • Access changesOpen connections are rechecked, so a downgraded plan or an ended trial closes the socket.
  • Falling behindA handler slower than the stream builds a backlog that only grows until something gives.
  • Too many socketsReconnect code that never closes the old socket hits the connection cap: the upgrade answers 429.

WebSocket close codes you will actually see

A close code tells you who ended the connection and whether coming back immediately is a good idea. Before any frame is exchanged, the upgrade itself can also fail with a plain HTTP status, and those need different handling:

SignalMeaningReconnect?How soon
Close 1000Normal close, usually one you asked for.Only if you still want dataBackoff ladder
Close 1006No close frame at all. A reset on the network path, not a server decision.YesBackoff ladder
Close 1012Planned release, preceded by a SERVER_RESTART system frame.YesRight away, with 0 to 1 s of jitter
Close 4008HEARTBEAT_TIMEOUT: your client stopped answering pings.Yes, then fix the causeBackoff ladder
Other 4000-range closeAn account-level decision, such as access that ended.CarefullyLong delays; alert a human
HTTP 401 or 403Bad key, or no streaming on this plan.NoNever; stop and report
HTTP 429WS_CONNECTION_LIMIT_EXCEEDED: too many open connections.Yes, after closing a stray socketBackoff ladder
HTTP 503Auth check timeout or a release in progress, with Retry-After.YesAt least Retry-After seconds
Close codes and upgrade statuses on the TickerLayer stream, with the policy the code below implements.

The 1006 row deserves a second look, because it is the code people search for. 1006 is never sent by anyone. It is what your library reports when the TCP connection vanished without the closing handshake, so it tells you nothing about the cause except that nobody said goodbye. Treat it as a network event: back off and retry.

Backoff with jitter

step = min(cap, base × 2^attempt)wait = step ÷ 2 + random(0, step ÷ 2)

base
The first delay: 1 second.
cap
The longest delay: 30 seconds.
attempt
Failures in a row, starting at 0. Reset after a healthy connection.
wait
Equal jitter: half the step is guaranteed, half is random.
Fourth failure in a row: step = min(30, 1 × 2³) = 8 s, so the client waits between 4 and 8 seconds.

Reconnect wait by failure count

  • Longest wait (s)
  • Shortest wait (s)

seconds

Computed from the formula with base 1 s and cap 30 s. Every client lands somewhere between the two lines.

The jitter is not decoration. When a release closes every connection with 1012 at the same instant, clients without jitter return in the same instant and meet each other at the door. With it, the reconnects spread out. For 1012 specifically the code below skips the ladder and waits a random 0 to 1 second: the server asked you to come back, just not all at once.

Resetting the ladder is the other half. Reset it when a connection opens and a server that accepts then immediately drops you creates a tight loop. Reset it only after the connection stayed up for a while (30 seconds below) and the ladder does its job.

WebSocket heartbeat: detect the connections that never close

The dangerous failure is not the close you get, it is the one you do not. After a laptop sleeps or a middlebox silently drops state, the socket can stay "open" on your side for minutes while nothing arrives. TCP will not tell you quickly. A heartbeat will.

Your clientTickerLayer stream
  1. protocol pingsent by the serverTickerLayer stream to Your client
  2. protocol pongsent by your library, automaticallyYour client to TickerLayer stream
  3. {"action":"ping"}your timer, every 10 sYour client to TickerLayer stream
  4. {"type":"system","event":"pong","ts":...}TickerLayer stream to Your client
  5. nothing received for 30 sterminate and reconnect
Two heartbeats. The server checks you with protocol pings; you check the server with JSON pings.

The server side is automatic: ws, browsers and Python websockets all answer protocol pings. Your side is a timer. Every 10 seconds, if anything at all (a quote, a trade, a pong) arrived recently, send {"action":"ping"}; if nothing arrived for 30 seconds, the socket is dead whatever its state says, so destroy it and reconnect. Since a healthy connection always answers the ping, the silence check cannot fire on a quiet symbol.

A WebSocket client in Node.js that reconnects

The client below uses the ws package and streams BTCUSD quotes and trades plus EURUSD quotes. Everything it knows about the session lives in SUBSCRIPTIONS, which it resends after every ready frame.

stream.mjsJavaScript
// npm install ws   (Node.js 18 or newer)
import WebSocket from "ws";

const URL =
  "wss://stream.tickerlayer.com/?apiKey=" +
  encodeURIComponent(process.env.TICKERLAYER_API_KEY);

// Everything this process wants. Re-sent, in full, after every reconnect.
const SUBSCRIPTIONS = [
  { action: "subscribe", channels: ["crypto.quotes", "crypto.trades"], symbols: ["BTCUSD"] },
  { action: "subscribe", channels: ["forex.quotes"], symbols: ["EURUSD"] },
];

const BASE_MS = 1_000;     // first retry after about a second
const MAX_MS = 30_000;     // never wait longer than this
const STABLE_MS = 30_000;  // a connection that lived this long resets the ladder
const PING_MS = 10_000;    // our JSON ping; the server answers with a pong frame
const SILENT_MS = 30_000;  // no frame at all for this long: the socket is dead
const LAG_WARN_MS = 5_000; // a trade older than this on arrival: we are behind

let attempt = 0;
let stopped = false;

// Equal jitter: half the exponential step is fixed, half is random, so a
// thousand clients dropped at the same moment do not return at the same moment.
function nextDelay() {
  const step = Math.min(MAX_MS, BASE_MS * 2 ** attempt);
  attempt += 1;
  return Math.round(step / 2 + Math.random() * (step / 2));
}

function onMarketFrame(msg) {
  if (msg.type === "trade" && !msg.snapshot && Date.now() - msg.ts > LAG_WARN_MS) {
    console.warn(`falling behind: this trade is ${Date.now() - msg.ts} ms old`);
  }
  if (msg.type === "quote") {
    console.log(`${msg.symbol} bid ${Number(msg.bid)} ask ${Number(msg.ask)}${msg.snapshot ? " (snapshot)" : ""}`);
  }
  // Keep this function fast. Hand heavy work (database writes, indicators) to a queue.
}

function connect() {
  const ws = new WebSocket(URL, { perMessageDeflate: false, handshakeTimeout: 10_000 });
  let openedAt = 0;
  let lastFrameAt = Date.now();
  let retryAfterMs = 0;
  let timer;

  // The upgrade was refused with a plain HTTP status; no frames were exchanged.
  ws.on("unexpected-response", (req, res) => {
    const status = res.statusCode;
    retryAfterMs = Number(res.headers["retry-after"] ?? 0) * 1_000;
    if (status === 401 || status === 403) {
      console.error(`upgrade refused with ${status}: fix the key or the plan, not the network`);
      stopped = true;
    } else {
      console.warn(`upgrade refused with ${status}; will retry`);
    }
    ws.terminate(); // emits "error", then "close" with 1006
  });

  ws.on("open", () => {
    openedAt = Date.now();
    timer = setInterval(() => {
      if (Date.now() - lastFrameAt > SILENT_MS) {
        console.warn("no frames and no pong: treating the socket as dead");
        ws.terminate();
        return;
      }
      ws.send(JSON.stringify({ action: "ping" }));
    }, PING_MS);
  });

  ws.on("ping", () => { lastFrameAt = Date.now(); }); // ws answers with a pong for us

  ws.on("message", (data) => {
    lastFrameAt = Date.now();
    const msg = JSON.parse(data.toString());
    if (msg.type === "system" && msg.event === "ready") {
      for (const sub of SUBSCRIPTIONS) ws.send(JSON.stringify(sub));
    } else if (msg.type === "system" && msg.event === "disconnect") {
      console.warn(`server is closing the stream: ${msg.code}`);
    } else if (msg.type === "error") {
      // A rejected subscribe leaves the socket open. Reconnecting will not fix it.
      console.error(`error frame ${msg.code}: ${msg.message}`, msg.rejected ?? "");
    } else if (msg.type === "system") {
      if (msg.event !== "pong") console.log(`${msg.event}: ${msg.channels} ${msg.symbols}`);
    } else {
      onMarketFrame(msg);
    }
  });

  ws.on("error", (err) => console.error(`socket error: ${err.message}`)); // "close" follows

  ws.on("close", (code) => {
    clearInterval(timer);
    if (stopped) return;
    if (openedAt && Date.now() - openedAt > STABLE_MS) attempt = 0;
    let delay;
    if (code === 1012) {
      delay = Math.round(Math.random() * 1_000); // planned release: come back now, spread out
    } else {
      delay = Math.max(nextDelay(), retryAfterMs);
    }
    if (code === 4008) console.warn("4008: the process did not answer heartbeats (blocked event loop?)");
    console.log(`closed with ${code}; reconnecting in ${delay} ms`);
    setTimeout(connect, delay);
  });

  return ws;
}

connect();

We ran it against the live stream and cut the connection five seconds in with terminate(), which destroys the socket without a close handshake, just like a network drop:

Output (trimmed)
subscribed: crypto.quotes,crypto.trades BTCUSD
subscribed: forex.quotes EURUSD
EURUSD bid 1.13723 ask 1.13724
BTCUSD bid 82974.87 ask 82974.88
EURUSD bid 1.137228 ask 1.137237
...
closed with 1006; reconnecting in 800 ms
subscribed: crypto.quotes,crypto.trades BTCUSD
subscribed: forex.quotes EURUSD

Handing it a wrong key shows the other branch. The upgrade is refused before any frame, the client prints upgrade refused with 401: fix the key or the plan, not the network, and the process exits instead of retrying forever.

  1. unexpected-responseFires when the upgrade gets a plain HTTP answer. 401 and 403 stop the client; anything else is retried, honouring Retry-After.
  2. openStarts the heartbeat timer. Nothing is sent yet: the server speaks first.
  3. messageResubscribes on ready, logs SERVER_RESTART and error frames, and passes market frames on. Error frames never trigger a reconnect.
  4. closeClears the timer, resets the ladder if the connection was healthy, picks the delay by close code and schedules connect() again.

The same logic in the browser

The browser WebSocket never reconnects on its own and hides more than Node does. A refused upgrade (401 or 403) arrives as a bare close 1006, so the client counts closes that happened before any ready frame and gives up after five in a row. There is no terminate(), so a dead socket is detached from its handlers and replaced without waiting for a close handshake that may never come. And the online event lets the page skip the rest of the backoff when the network returns.

browser-stream.jsJavaScript
// Browser version. Only for a private, signed-in tool: the key sits in the URL,
// so anyone who can open the page can read it. Public sites need a relay.
function openStream({ apiKey, subscriptions, onFrame }) {
  const url = "wss://stream.tickerlayer.com/?apiKey=" + encodeURIComponent(apiKey);
  const lifetime = new AbortController();
  let ws;
  let attempt = 0;
  let refusals = 0;      // closes before any ready frame, in a row
  let healthySince = 0;  // when the current socket sent its ready frame
  let lastFrameAt = 0;
  let pingTimer;
  let retryTimer;

  function connect() {
    clearTimeout(retryTimer);
    healthySince = 0;
    const socket = new WebSocket(url);
    ws = socket;

    socket.onopen = () => {
      lastFrameAt = Date.now();
      pingTimer = setInterval(() => {
        if (Date.now() - lastFrameAt > 30_000) return drop(socket); // half-open
        socket.send(JSON.stringify({ action: "ping" }));
      }, 10_000);
    };

    socket.onmessage = (event) => {
      lastFrameAt = Date.now();
      const msg = JSON.parse(event.data);
      if (msg.type === "system" && msg.event === "ready") {
        healthySince = Date.now();
        refusals = 0;
        for (const sub of subscriptions) socket.send(JSON.stringify(sub));
      } else if (msg.type !== "system") {
        onFrame(msg); // quote, trade and error frames
      }
    };

    // Browsers hide the HTTP status of a refused upgrade: 401 and 403 arrive as 1006.
    socket.onclose = (event) => {
      if (!healthySince) refusals += 1;
      scheduleReconnect(event.code);
    };
  }

  function drop(socket) {
    socket.onclose = null; // do not wait for a close handshake that may never come
    socket.close();
    scheduleReconnect(1006);
  }

  function scheduleReconnect(code) {
    clearInterval(pingTimer);
    if (refusals >= 5) {
      console.error("refused five times in a row: check the key and the plan");
      return;
    }
    if (healthySince && Date.now() - healthySince > 30_000) attempt = 0;
    const step = Math.min(30_000, 1_000 * 2 ** attempt++);
    const delay = code === 1012 ? Math.random() * 1_000 : step / 2 + Math.random() * (step / 2);
    retryTimer = setTimeout(connect, delay);
  }

  // Back from sleep or a network switch: skip the rest of the backoff.
  window.addEventListener("online", () => {
    if (ws.readyState === WebSocket.CLOSED) connect();
  }, { signal: lifetime.signal });

  connect();

  return function stop() {
    lifetime.abort();
    clearTimeout(retryTimer);
    clearInterval(pingTimer);
    ws.onclose = null;
    ws.close(1000);
  };
}

// Usage, in a private tool where each signed-in user supplies their own key.
const stop = openStream({
  apiKey: localStorage.getItem("tickerlayer-key"),
  subscriptions: [{ action: "subscribe", channels: ["forex.quotes"], symbols: ["EURUSD"] }],
  onFrame: (msg) => {
    if (msg.type === "quote") {
      document.querySelector("#eurusd").textContent = `${Number(msg.bid).toFixed(5)} / ${Number(msg.ask).toFixed(5)}`;
    } else if (msg.type === "error") {
      console.warn(`error frame ${msg.code}: ${msg.message}`);
    }
  },
});
window.addEventListener("pagehide", stop);

Slow consumers and WebSocket compression

A socket delivers frames in order, one at a time. If your handler takes longer per frame than the stream takes to produce one, frames queue up in your process, every price you act on is older than the last, and the queue only grows. Eventually the process is too busy to answer heartbeats and the server closes it, which looks like a network problem and is not.

Per-message compression makes this worse. With it negotiated, the client has to inflate every frame one after another before it can even parse it, and on a busy stream that inflation alone can put a client seconds behind. The TickerLayer stream sends uncompressed frames. Turn per-message compression off in your client (perMessageDeflate: false in Node's ws, compression=None in Python websockets) so your process never spends time inflating frames on a busy stream, and keep your message handler fast: hand heavy work to a queue or a worker instead of doing it inside the socket callback.

Keeps up

  • Handler parses, updates an in-memory map and returns.
  • Database writes are batched from a queue by a separate task.
  • Indicators recompute on a timer, not per tick.
  • Compression is off on the client.
  • A lag check warns when trade timestamps drift behind the clock.

Falls behind

  • One database insert awaited per tick.
  • Charts re-rendered on every frame.
  • Synchronous file writes inside the callback.
  • Per-message compression negotiated on a high-rate socket.
  • Nobody notices until the server closes with 4008.

The LAG_WARN_MS check in the Node client is the early warning. It compares each live trade's ts with the local clock and warns when the difference passes five seconds. It needs a synchronised clock (NTP) to mean anything, and the threshold is yours to tune; the point is to hear about a backlog while it is small.

WebSocket reconnect without a resubscribe storm

Before you ship the reconnect loop

  • Subscriptions live in one list, and every ready frame resends all of it.
  • Lists longer than 500 symbols are split into several subscribe messages (TOO_MANY_SYMBOLS otherwise).
  • The old socket is closed or terminated before a new one opens, so a reconnect never holds two connections against your WebSocket limits.
  • Error frames are logged, not treated as disconnects.
  • Snapshot frames that are not newer than your last stored ts are dropped.
  • Bars that closed during the gap are backfilled over REST.
  • HTTP 401 and 403 stop the client; everything else backs off with jitter.
  • The key never appears in logs, since it is part of the URL.

A connection cap refusal (HTTP 429 on the upgrade) is the stream's version of a rate limit, and it usually means a previous socket is still alive. The general mechanics of retrying rate-limited requests are in 429 Too Many Requests, and the same loop in asyncio is in the Python WebSocket client tutorial. The troubleshooting reference lists the remaining fixes, from URL encoding to symbol formats.

Questions

What does WebSocket error 1006 mean?

Close code 1006 means the connection ended without a closing handshake, so no side sent a close frame. It usually points at a network reset, a proxy timeout or a sleeping device; reconnect with backoff.

How do I reconnect a WebSocket automatically in JavaScript?

Listen for the close event, wait an exponentially growing delay with random jitter, create a new WebSocket and resubscribe once it is ready. The browser API never reconnects by itself.

What is a good WebSocket heartbeat interval?

A client-side ping every 10 to 30 seconds, with the connection declared dead after two or three missed intervals, is a common and cheap choice. Protocol pings from the server are answered by your library automatically.

Should I enable WebSocket compression for market data?

Not for a high-rate feed. Inflating every frame costs CPU in the order frames arrive and can make a client fall behind; the TickerLayer stream sends uncompressed frames, so set perMessageDeflate: false in Node or compression=None in Python.

What is WebSocket close code 1012?

Service restart: the server is going away on purpose and expects you back. On TickerLayer it follows a SERVER_RESTART system frame; reconnect within a second and resubscribe.

Keep reading

Ready to integrate?

Start with the free tier, explore the docs, and connect via REST or WebSocket in minutes.