Blog
WebSocketsTrading systemsNode.jsJavaScriptBybit

Engineering Reliable Crypto WebSocket Data Feeds in Node.js

Build a public Bybit ticker feed in Node.js. Merge snapshots and deltas, track data freshness, clear prices on reconnect, and close sockets cleanly.

Siebly.io4 min readMarkdown

Overview

A WebSocket connection can stay open while the prices in your application are stale. The connection is only one part of a market-data feed: you also need to apply partial updates, know when a snapshot is missing, and stop serving old values after a reconnect.

This walkthrough uses the Siebly Bybit SDK, bybit-api, to follow BTCUSDT and ETHUSDT linear-contract tickers in one Node.js process. It keeps one current price per symbol and prints a summary once a second. Both subscriptions are public, so the program needs no API credentials and sends no orders.

Choose the product category before subscribing

Install the package in a new directory:

Imported example

Shell
npm init -y

npm install bybit-api

The examples below were checked with Node.js 24.19.0 and bybit-api 4.8.2. Save both files in the same directory. Their .mjs extension enables JavaScript modules without changing package.json.

Bybit has separate public WebSocket endpoints for spot, linear, inverse, and options markets. subscribeV5(topics, 'linear') tells the SDK which endpoint owns these subscriptions. Public topics do not require authentication. See Bybit's connection documentation for the endpoint list and connection limits.

Keep category in the identity of a subscription if you later add another market. A spot BTCUSDT price and a linear BTCUSDT price are different observations, even though their symbol strings match. This program uses only the linear endpoint, so a symbol is sufficient as its local map key.

Apply a snapshot before accepting deltas

The Bybit ticker contract distinguishes snapshots from deltas. Linear ticker deltas omit fields that have not changed. A delta containing a new bid but no lastPrice leaves the preceding last price intact. Spot tickers use snapshot messages, so do not apply one exchange-wide merge rule without checking the topic.

Save this as ticker-state.mjs:

Imported example

JavaScript
export function applyTicker(previous, message, receivedAt) {
  const { type, data, ts } = message;

  if (type !== 'snapshot' && type !== 'delta') return previous;

  if (!data || typeof data.symbol !== 'string' || !Number.isFinite(ts)) {
    return previous;
  }

  if (type === 'delta' && previous?.symbol !== data.symbol) return previous;

  const price =
    data.lastPrice === undefined && type === 'delta'
      ? previous.lastPrice
      : data.lastPrice;

  if (typeof price !== 'string' || price.length === 0) return previous;

  return { symbol: data.symbol, lastPrice: price, exchangeTime: ts, receivedAt };
}

This function retains only the fields the example needs. It rejects a delta without a preceding snapshot for the same symbol, validates the price and exchange timestamp, and keeps prices as decimal strings. JavaScript floating-point conversion is unnecessary for displaying a price.

receivedAt uses the application's monotonic clock. exchangeTime keeps the exchange timestamp separately. The first measures local time since a valid message; the second describes the exchange's event time. Neither alone measures end-to-end transport latency.

Subscribe, report freshness, and close cleanly

Save this as feed.mjs:

Imported example

JavaScript
import { performance } from 'node:perf_hooks';

import { WebsocketClient, WS_KEY_MAP } from 'bybit-api';

import { applyTicker } from './ticker-state.mjs';

const symbols = ['BTCUSDT', 'ETHUSDT'];

const topics = symbols.map((symbol) => `tickers.${symbol}`);

const latest = new Map();

const ws = new WebsocketClient();

const staleAfterMs = 5_000;

let stopped = false;

function stop(code = 0) {
  if (stopped) return;

  stopped = true;

  clearInterval(report);

  clearTimeout(deadline);

  ws.closeAll();

  process.exitCode = code;
}

ws.on('update', (message) => {
  if (stopped || message.wsKey !== WS_KEY_MAP.v5LinearPublic) return;

  if (!topics.includes(message.topic)) return;

  const symbol = message.topic.slice('tickers.'.length);

  if (message.data?.symbol !== symbol) return;

  const next = applyTicker(latest.get(symbol), message, performance.now());

  if (next) latest.set(symbol, next);
});

function invalidate({ wsKey }) {
  if (wsKey === WS_KEY_MAP.v5LinearPublic) latest.clear();
}

ws.on('reconnect', invalidate);

ws.on('close', invalidate);

ws.on('exception', (error) => {
  console.error('WebSocket error:', error);

  stop(1);
});

ws.on('response', (response) => {
  if (response.op === 'subscribe' && response.success === false) {
    console.error('Subscription rejected:', response);

    stop(1);
  }
});

const report = setInterval(() => {
  console.table(
    symbols.map((symbol) => {
      const ticker = latest.get(symbol);

      if (!ticker) return { symbol, status: 'waiting for snapshot' };

      const ageMs = Math.round(performance.now() - ticker.receivedAt);

      return {
        symbol,

        status: ageMs > staleAfterMs ? 'stale' : 'current',

        lastPrice: ticker.lastPrice,

        ageMs,

        exchangeTime: ticker.exchangeTime,
      };
    }),
  );
}, 1_000);

const deadline = setTimeout(() => stop(), 20_000);

process.once('SIGINT', () => stop());

Promise.all(ws.subscribeV5(topics, 'linear')).catch((error) => {
  console.error('Could not subscribe:', error);

  stop(1);
});

Run it with:

Imported example

Shell
node feed.mjs

After the snapshots arrive, the output has one row per symbol: status, lastPrice, ageMs, and exchangeTime. The script stops after 20 seconds; Ctrl+C also closes its sockets. If the subscription is rejected or the SDK reports an exception, it prints the error and exits with a nonzero status.

The five-second freshness threshold is a choice for this demonstration. Choose a threshold for your consumer and instrument it. A lack of ticker changes can make a quiet market appear stale even when the socket is connected. Conversely, updates to other ticker fields can keep the feed current while the last traded price remains unchanged. Here, current means a valid ticker message arrived recently; it does not mean a new trade occurred.

The SDK handles heartbeats, reconnects, and resubscription. The application clears its retained prices on reconnect or close, then waits for fresh snapshots. It does not treat a reopened connection as proof that its old values are usable. An exception deliberately ends this short example so a failed first run is visible.

Keep slow work outside the message handler

The update handler performs a bounded map update. The once-per-second reporter reads that map independently. This keeps the example from printing or writing every ticker event to a database.

For a dashboard that needs only the latest value, overwriting each symbol's entry bounds memory by the number of subscribed symbols. A recorder that must retain every event needs a different design: a bounded queue, a measured drain rate, and an explicit response when the queue fills. Dropping events silently would make a recording incomplete. Moving work to an unbounded array would merely postpone the failure until memory runs out.

When adding more symbols, check the exchange's subscription limits and group requests within those limits. Reuse the SDK client so its connection routing can group topics by endpoint. Do not create a fresh socket for every message or retry.

Check the state transitions before extending the feed

The live smoke check for this walkthrough received both public ticker streams and closed normally. Separate local checks exercised these cases:

  • A delta arriving before its snapshot does not create a price record.
  • A delta without lastPrice preserves the preceding price.
  • An invalid price or timestamp does not refresh the record's age.
  • A message for a different symbol cannot overwrite a record.
  • Clearing state after a reconnect makes the next delta insufficient until a snapshot arrives.

A successful connection does not test all of those transitions. Keep them as fixtures when you add persistence, consumers, or more market categories. Also check your actual reconnect behavior by interrupting a development connection and observing the return to snapshots.

For the SDK's REST calls, private streams, and environment selection, continue with the Bybit JavaScript tutorial. The Bybit SDK guide has the shorter first-request path and package links.

Related articles

Continue from here

Related Siebly resources

All articles

Subscribe on Substack

Complete the Substack form below to join our newsletter. Substack handles all subscriber data directly.