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
- Why long-lived connections drop
- WebSocket close codes you will actually see
- Backoff with jitter
- WebSocket heartbeat: detect the connections that never close
- A WebSocket client in Node.js that reconnects
- The same logic in the browser
- Slow consumers and WebSocket compression
- WebSocket reconnect without a resubscribe storm
- 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_RESTARTframe 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:
| Signal | Meaning | Reconnect? | How soon |
|---|---|---|---|
| Close 1000 | Normal close, usually one you asked for. | Only if you still want data | Backoff ladder |
| Close 1006 | No close frame at all. A reset on the network path, not a server decision. | Yes | Backoff ladder |
| Close 1012 | Planned release, preceded by a SERVER_RESTART system frame. | Yes | Right away, with 0 to 1 s of jitter |
| Close 4008 | HEARTBEAT_TIMEOUT: your client stopped answering pings. | Yes, then fix the cause | Backoff ladder |
| Other 4000-range close | An account-level decision, such as access that ended. | Carefully | Long delays; alert a human |
| HTTP 401 or 403 | Bad key, or no streaming on this plan. | No | Never; stop and report |
| HTTP 429 | WS_CONNECTION_LIMIT_EXCEEDED: too many open connections. | Yes, after closing a stray socket | Backoff ladder |
| HTTP 503 | Auth check timeout or a release in progress, with Retry-After. | Yes | At least Retry-After seconds |
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.
Reconnect wait by failure count
- Longest wait (s)
- Shortest wait (s)
seconds
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.
- protocol pingsent by the serverTickerLayer stream to Your client
- protocol pongsent by your library, automaticallyYour client to TickerLayer stream
- {"action":"ping"}your timer, every 10 sYour client to TickerLayer stream
- {"type":"system","event":"pong","ts":...}TickerLayer stream to Your client
- nothing received for 30 sterminate and reconnect
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.
// 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:
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 EURUSDHanding 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.
- unexpected-responseFires when the upgrade gets a plain HTTP answer. 401 and 403 stop the client; anything else is retried, honouring
Retry-After. - openStarts the heartbeat timer. Nothing is sent yet: the server speaks first.
- messageResubscribes on
ready, logsSERVER_RESTARTand error frames, and passes market frames on. Error frames never trigger a reconnect. - 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 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
readyframe resends all of it. - Lists longer than 500 symbols are split into several subscribe messages (
TOO_MANY_SYMBOLSotherwise). - 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
tsare 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.