A DEX dashboard that streams swap ticks works fine on a laptop for ten minutes, then goes quiet. No thrown exception, no close handler that fired, just a chart that keeps showing the last price while the pool keeps trading. The feed didn't crash. It was dropped, and nothing in the code noticed.
That failure mode is specific to long-lived WebSocket connections, and it's the main thing that separates a demo subscriber from one you can leave running. This post walks through a resilient subscriber for the SiftingIO dex product: the chain:PAIR symbol format, the authenticated handshake, the last-cached-then-live emit behavior on subscribe, and the keepalive ping that stops the server from closing an idle socket out from under you.
Why the feed goes quiet#
The stream endpoint is wss://stream.sifting.io/ws/v1. You authenticate either with a ?key=sft_*** query parameter on the connect URL, or by sending an explicit {"op":"auth","key":"..."} frame right after the socket opens. Once auth succeeds the server replies with an ack frame that carries your tier, so you know the connection is live and entitled before any data flows.
The dex product streams on-chain swap activity, and its symbols use a chain:PAIR shape: eth:WETH-USDC, base:WETH-USDC, arbitrum:WETH-USDC. One connection can carry pairs from Ethereum, Base, and Arbitrum at the same time, under a single credential. Each DEX tick comes back with a chain field alongside the symbol, so you can route a tick to the right chart even when several chains share the socket.
The quiet-feed problem comes from one rule: if the server sees no client frames for 90 seconds, it closes the connection. A pure subscriber that only listens and never sends anything will trip this the moment the market goes slow, because an idle pool produces no inbound traffic to keep you busy and no outbound traffic to reset the timer. The fix is a client ping sent at least every 60 seconds. The server answers each one with a pong frame, which also gives you a cheap liveness signal.
A subscriber that reconnects cleanly#
The pattern below covers the three things that actually break in production: the keepalive ping, capped exponential backoff on reconnect, and re-subscribing after every reconnect. It uses the ws package on Node 18+.
import WebSocket from "ws";
const URL = "wss://stream.sifting.io/ws/v1";
const KEY = process.env.SIFTING_KEY;
const SUBSCRIPTIONS = [
{ product: "dex", symbols: ["eth:WETH-USDC", "base:WETH-USDC", "arbitrum:WETH-USDC"] },
];
let ws, pingTimer, backoff = 1000;
function connect() {
// Query-param auth; the {"op":"auth","key":"..."} frame is the alternative.
ws = new WebSocket(`${URL}?key=${KEY}`);
ws.on("open", () => {
backoff = 1000; // reset only after a clean open
for (const sub of SUBSCRIPTIONS) {
ws.send(JSON.stringify({ op: "subscribe", ...sub }));
}
clearInterval(pingTimer);
pingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ op: "ping" }));
}, 60_000); // under the 90s idle close
});
ws.on("message", (raw) => {
const m = JSON.parse(raw);
switch (m.f) { // every frame discriminates on f
case "ack": break; // op or auth ok; first ack carries the tier
case "pong": break; // keepalive reply
case "tick": onTick(m); break;
case "tvl": onTvl(m); break;
case "error": console.error("ws error", m.code); break;
}
});
ws.on("close", scheduleReconnect);
ws.on("error", () => ws.close()); // funnel errors into the close path
}
function scheduleReconnect() {
clearInterval(pingTimer);
setTimeout(connect, backoff);
backoff = Math.min(backoff * 2, 30_000); // capped exponential backoff
}
function onTick(m) {
// DEX ticks carry chain; s is the chain:PAIR you subscribed to, t is epoch ms.
console.log(m.chain, m.s, m.p, m.t);
}
function onTvl(m) { /* pool TVL deltas: usd, reserves r0/r1, swap count n */ }
connect();
The message handler branches on the f field, which every server frame carries. You'll see tick for price and quote updates, tvl for pool TVL deltas, ack for a successful op, pong for keepalives, and error with a code such as auth_failed, unknown_product, max_subscriptions, or max_connections. Reading the code off the error frame tells you whether the problem is your credential, your payload, or your plan's limits, rather than leaving you to guess from a bare close event.
The backoff reset is deliberately inside the open handler, not at the top of connect. If you reset it before the socket actually opens, a server that's down will let you hammer it at the floor delay forever, because each failed attempt resets the counter it was supposed to grow. Resetting only on a confirmed open keeps the backoff honest.
The last-cached-then-live emit, and what to do with it#
On subscribe, the server first emits the last cached value for each symbol, then switches to live updates. This is useful: a dashboard paints a real price immediately instead of an empty cell while it waits for the next swap. It also means the first frame per symbol can be older than "now," and the t timestamp (int64 Unix epoch milliseconds) is how you tell. If you stamp every incoming tick with the wall-clock arrival time instead of reading t, a thin pool that last traded an hour ago will look like it just printed, and any staleness logic you build on top will be wrong from the first frame.
Treat the cached emit as a seed for current state, and trust t for anything time-sensitive. If you compute a freshness indicator, compute it from t, not from when your handler ran.
Common pitfalls#
The first is reconnecting without re-subscribing. Subscriptions live on the connection, not on your API key, so a socket that drops and comes back is a blank slate. A reconnect loop that only re-establishes the socket will connect successfully, log nothing wrong, and stream zero ticks. The handler above re-sends every subscription inside open for exactly this reason.
The second is the ping interval. Sixty seconds is the right cadence because the idle close fires at ninety, and you want margin for a slow round trip. Setting the timer at the laptop, then suspending it, then waking on a flaky network is the realistic failure: pick 60s, not 85s, so a single delayed ping doesn't cross the limit. If the feed dies silently every minute or two, the ping is the first thing to check.
The third is stacking duplicate subscriptions across reconnects. If you add to your subscription list on the fly and your reconnect logic replays an ever-growing set, you can trip max_subscriptions and get an error frame instead of data. Free-tier connections allow a small number of symbols; higher tiers allow far more. Keep one canonical subscription set, replay that exact set on reconnect, and read the code on any error frame so a limit shows up as a clear message rather than a feed that never starts.
A resilient subscriber is mostly these few habits: ping under the idle window, back off on a capped curve, re-subscribe on every open, and read t instead of trusting arrival time. Wire those in once and the feed survives the long quiet stretches that break the naive version.
Read the docs for the full WebSocket frame reference and the per-tier connection and subscription limits.

