---
title: "How to Stream Crypto Market Data in Node.js"
description: "Master how to stream crypto market data Node.js applications demand. Build resilient WebSocket feeds, handle heartbeats, and eliminate drops using Siebly SDKs."
canonical: "https://siebly.io/blog/how-to-stream-crypto-market-data-in-nodejs-a-resilient-engineering-guide"
---

# How to Stream Crypto Market Data in Node.js: A Resilient Engineering Guide

Master how to stream crypto market data Node.js applications demand. Build resilient WebSocket feeds, handle heartbeats, and eliminate drops using Siebly SDKs.

## Technical Disclaimer

These articles are software engineering references for exchange API integrations. They are not financial, investment, legal, tax, compliance, or trading advice. Use public data, demo, testnet, or paper workflows first. Keep API credentials out of frontend code and disable withdrawal permissions for automation keys.

## Overview {#overview}

A raw WebSocket client will sit there looking connected while the exchange has already dropped you. Firewalls and load balancers do this all the time: the TCP session dies, no close frame shows up, and your process keeps waiting. If you have watched that happen on Binance, Bybit, or OKX, you already know a thin wrapper around `ws` is not enough.

Each venue also wants a different heartbeat, a different topic shape, and a different way to keep a local order book honest. This guide uses the Siebly.io SDKs for that work: [binance](/sdk/binance/javascript), [bybit-api](/sdk/bybit/javascript), [okx-api](/sdk/okx/javascript), [@siebly/kraken-api](/sdk/kraken/javascript), and [coinbase-api](/sdk/coinbase/javascript). They open the socket, send the heartbeat that venue actually expects, and subscribe again after a drop. They do not pace your traffic. Quotas, queues, and backpressure stay in your process.

Use a current Node.js LTS (22 or 24). The samples below are shortened from the example folders in those packages. Official exchange docs stay the source of truth for limits and payload fields.

## Key Takeaways {#key-takeaways}

- Heartbeats and reconnects are already inside the WebSocket clients. You listen for events. You do not call `socket.terminate()` yourself.
- Topic names are not shared. `publicTrade.BTCUSDT` is Bybit. `btcusdt@trade` is Binance. `{ channel: 'trades', instId: 'BTC-USDT' }` is OKX.
- A local order book is not one algorithm. Binance diff depth needs a REST snapshot. Bybit and OKX push a snapshot on the socket first.
- `WebsocketAPIClient` methods return a Promise on Binance, Bybit, OKX, and Kraken. Coinbase orders stay on the REST client. `sendWSAPIRequest()` on [coinbase-api](/sdk/coinbase/javascript) is a stub and should not be used.
- None of these SDKs throttle you. Bybit can surface rate-limit headers on REST responses. That is information, not a limiter.



## What actually kills a market-data socket {#what-actually-kills-a-market-data-socket}

Market data is a long-lived [WebSocket](https://en.wikipedia.org/wiki/WebSocket), not a poll loop. Node.js still has one event loop. `JSON.parse()` runs on that thread inside the client before your handler sees the message. A slow handler, or a giant book snapshot, will delay timers. If heartbeats slip, the exchange closes you.

The clients emit events so your code can stay small. They do not move parsing onto a worker thread. If a callback sorts a full book or writes to disk, that cost is still yours. Keep the listener thin and hand the payload off.

### Half-open connections {#half-open-connections}

A socket can die without an `error` or `close` event. The process thinks it is fine. Nothing arrives. Every venue then has its own rule for when to cut an idle or stale session:

- Binance sends a protocol ping frame about every 20 seconds and expects a pong within a minute. A single connection is also torn down after 24 hours. The [binance](/sdk/binance/javascript) client treats that 24 hour drop as a normal reconnect, and it runs its own heartbeat on top (default `pingInterval` of 10 seconds, `pongTimeout` of 5 seconds).
- Bybit wants an application message, `{"op":"ping"}`. Their docs recommend sending it every 20 seconds. If there is no ping-pong and no stream data from the server, they cut the connection after about 10 minutes. [bybit-api](/sdk/bybit/javascript) sends `{"op":"ping"}` for you, every 10 seconds by default.
- OKX wants the plain text frame `ping`, and replies with `pong`. If a subscription never lands, or no data is pushed for 30 seconds, they close the socket. [okx-api](/sdk/okx/javascript) sends that text ping every 10 seconds by default.
- Kraken spot (v2) wants `{"method":"ping","req_id":...}`. Kraken derivatives use a protocol ping frame. [@siebly/kraken-api](/sdk/kraken/javascript) picks the right one from the socket key.
- Coinbase Advanced Trade uses protocol ping frames. [coinbase-api](/sdk/coinbase/javascript) calls `ws.ping()`.

OS-level TCP keep-alive does not satisfy any of those. You also should not reimplement them when you are on these clients. The timer, the payload, and the reconnect are already there. `pingInterval` and `pongTimeout` are options if you need to change the defaults.

## Raw sockets vs these clients {#raw-sockets-vs-these-clients}

You can build this on the `ws` package and the [RFC 6455](https://datatracker.ietf.org/doc/html/rfc6455) framing rules. You will then own subscription memory, sequence checks, backoff, and a different signature scheme per venue. Binance signs with HMAC, RSA, or Ed25519 (the WebSocket API is happiest with Ed25519). OKX needs a passphrase as well as key and secret. Bybit authenticates the private socket on connect.

The clients cover that plumbing:

- TypeScript types for requests and responses. That catches mistakes in your code at compile time. It does not inspect live payloads, and it will not notice a field rename on the exchange until the package types are updated.
- They remember topics and subscribe again after a reconnect.
- They emit lifecycle events. The event name is not the same on every package. Binance and Kraken emit `reconnecting`. Bybit, OKX, and Coinbase emit `reconnect`. All of them emit `reconnected` when the new socket is up.

They do not enforce exchange rate limits. Bybit spot does batch subscribe calls (at most 10 topics per message, 500 on the other v5 public sockets). That is packet shape, not a quota.

The package index is at [siebly.io/sdk](/sdk).

## Subscribing to trades and books {#subscribing-to-trades-and-books}

Install the package for the venue you are calling:

```bash title="Imported example"
npm install binance
npm install bybit-api
npm install okx-api
npm install @siebly/kraken-api
npm install coinbase-api
```

The npm name for Kraken is [@siebly/kraken-api](/sdk/kraken/javascript). The others match the folder names.

Public market data should stay on the public socket. Authenticated order entry is a different URL. You usually do not pass the URL yourself. The client picks it from the product you subscribe to.

### Binance {#binance}

Spot public streams default to `wss://stream.binance.com:9443` (ws key `main`). `wss://data-stream.binance.vision` is a market-data-only alternative (ws key `main3`). It is not the default, and it cannot carry user data.

Stream names are lowercase. Trades are `btcusdt@trade`. A partial book (a fresh top-of-book snapshot on a timer, not a diff) is `btcusdt@depth20@1000ms`. With `beautify: true`, parsed events come out on `formattedMessage`.

```ts title="Imported example"
import { isWsFormattedTrade, WebsocketClient } from "binance";

const wsClient = new WebsocketClient({ beautify: true });

wsClient.on("formattedMessage", (data) => {
  if (isWsFormattedTrade(data)) {
console.log("trade", data);
return;
  }
  console.log("other", data);
});

wsClient.on("reconnecting", ({ wsKey }) => {
  console.log("reconnecting", wsKey);
});
wsClient.on("reconnected", (data) => {
  console.log("reconnected", data?.wsKey);
});

wsClient.subscribeSpotTrades("BTCUSDT");
wsClient.subscribeSpotPartialBookDepth("BTCUSDT", 20, 1000);
```

`subscribeSpotPartialBookDepth` only accepts 5, 10, or 20 levels, and 100ms or 1000ms on spot. That topic is a snapshot stream. You do not merge it with `/api/v3/depth`.

More patterns are in the [Binance JavaScript tutorial](/sdk/binance/javascript/tutorial).

### Bybit {#bybit}

`subscribeV5(topic, category)` is the call you want. The category matters because each one has its own host. Spot public is `wss://stream.bybit.com/v5/public/spot`. Linear, inverse, and option are separate. Topics look like `publicTrade.BTCUSDT` and `orderbook.50.BTCUSDT`. Data arrives on `update`.

```ts title="Imported example"
import { WebsocketClient } from "bybit-api";

const wsClient = new WebsocketClient();

wsClient.on("update", (data) => {
  console.log(data);
});

wsClient.on("reconnect", ({ wsKey }) => {
  console.log("reconnecting", wsKey);
});
wsClient.on("reconnected", (data) => {
  console.log("reconnected", data?.wsKey);
});
wsClient.on("exception", (data) => {
  console.error("ws exception", data);
});

wsClient.subscribeV5("publicTrade.BTCUSDT", "spot");
wsClient.subscribeV5("orderbook.50.BTCUSDT", "spot");
```

Those topic strings are Bybit-only. Passing them to the Binance client will not work. The [Bybit JavaScript tutorial](/sdk/bybit/javascript/tutorial) walks through the same client.

### OKX {#okx}

Public channels are objects, not dotted topic strings. Global (`www.okx.com`) is the default. EEA users set `market: 'EEA'`. US users set `market: 'US'`.

```ts title="Imported example"
import { WebsocketClient } from "okx-api";

const wsClient = new WebsocketClient();

wsClient.on("update", (data) => {
  console.log(data);
});

wsClient.on("reconnect", ({ wsKey }) => {
  console.log("reconnecting", wsKey);
});
wsClient.on("reconnected", (data) => {
  console.log("reconnected", data?.wsKey);
});

wsClient.subscribe([
  { channel: "trades", instId: "BTC-USDT" },
  { channel: "books", instId: "BTC-USDT" },
]);
```

`books5` is a small snapshot channel. `books` is the incremental book. Instrument ids use a hyphen (`BTC-USDT`), not `BTCUSDT`.

### Kraken and Coinbase {#kraken-and-coinbase}

Kraken spot v2 takes a topic plus a payload, and you pass the socket key so the client knows which product you mean. Book messages include a snapshot by default.

```ts title="Imported example"
import { WebsocketClient, WS_KEY_MAP } from "@siebly/kraken-api";

const wsClient = new WebsocketClient();

wsClient.on("message", (data) => {
  console.log(data);
});

wsClient.on("reconnecting", (data) => {
  console.log("reconnecting", data);
});
wsClient.on("reconnected", (data) => {
  console.log("reconnected", data);
});

wsClient.subscribe(
  {
topic: "trade",
payload: { symbol: ["BTC/USD"] },
  },
  WS_KEY_MAP.spotPublicV2,
);

wsClient.subscribe(
  {
topic: "book",
payload: { symbol: ["BTC/USD"], depth: 10 },
  },
  WS_KEY_MAP.spotPublicV2,
);
```

Coinbase Advanced Trade market data uses the same idea, with product ids like `BTC-USD`. Updates arrive on `update`. The reconnect event is `reconnect`, not `reconnecting`.

```ts title="Imported example"
import { WebsocketClient } from "coinbase-api";

const wsClient = new WebsocketClient();

wsClient.on("update", (data) => {
  console.log(data);
});

wsClient.on("reconnect", (data) => {
  console.log("reconnecting", data);
});
wsClient.on("reconnected", (data) => {
  console.log("reconnected", data);
});

wsClient.subscribe(
  {
topic: "ticker",
payload: { product_ids: ["BTC-USD"] },
  },
  "advTradeMarketData",
);
```



## Keeping a local order book {#keeping-a-local-order-book}

Do not use one sync procedure for every venue. The `lastUpdateId` flow below is Binance spot diff depth. Bybit and OKX hand you the snapshot on the WebSocket.

### Binance diff depth {#binance-diff-depth}

`subscribeSpotDiffBookDepth` is the incremental stream (`btcusdt@depth@100ms`). Partial depth, shown above, is different. For the diff stream, Binance's own procedure is:

1. Subscribe first and buffer events.
2. Call `MainClient.getOrderBook()`, which hits `GET /api/v3/depth`.
3. Drop buffered events whose final update id `u` is less than or equal to the snapshot `lastUpdateId`.
4. The first event you apply must overlap that snapshot. After that, each event's first update id must follow the previous event's final update id. A gap means the book is wrong: clear it and start again.

With `beautify: true`, those ids show up as `firstUpdateId` and `lastUpdateId`. Raw events still use `U` and `u`. Futures also carry `pu` (`finalUpdateId` once beautified). The client does not build the book for you.

```ts title="Imported example"
import { MainClient, WebsocketClient } from "binance";

const restClient = new MainClient();
const wsClient = new WebsocketClient({ beautify: true });

wsClient.subscribeSpotDiffBookDepth("BTCUSDT", 100);

const snapshot = await restClient.getOrderBook({
  symbol: "BTCUSDT",
  limit: 1000,
});

console.log(snapshot.lastUpdateId);
```

The buffer, the overlap check, and the in-memory book sit in your code. The snippet only shows the two calls. For a longer look at snapshot-plus-stream pipelines, see [historical and live data pipelines](/ai/historical-live-data-pipeline).

### Bybit {#bybit}

`orderbook.50.BTCUSDT` sends a `snapshot`, then `delta` events. Levels are `[price, qty]`. The event includes update id `u` and cross sequence `seq`. Apply the snapshot, then deltas. If `seq` jumps, the book is stale and you subscribe again. `RestClientV5.getOrderbook()` exists (`GET /v5/market/orderbook`) if you want a REST copy. You do not need it to bootstrap this topic.

### OKX {#okx}

The `books` channel pushes a full book first, then incremental updates, with a checksum on the payload. If the checksum fails, resubscribe. `books5` is snapshot-only (top 5) and does not need that merge. `RestClient.getOrderBook()` calls `GET /api/v5/market/books` when you want a REST copy.

## Heartbeats, reconnects, and quotas {#heartbeats-reconnects-and-quotas}

On a raw `ws` socket, a dead session is cleared with `socket.terminate()`, not a graceful `close()`, so the process does not wait on a handshake that will never finish. These clients already do that internally when the pong timer fires. Calling `terminate()` on a socket you dug out of the client fights the reconnect logic. Listen for the lifecycle events instead, and keep your own book in a state you can rebuild. A reconnect is a gap. Treat the local book as dirty until you have a fresh snapshot.

Backoff matters when you are opening sockets yourself. A thundering herd during an outage gets you banned. Binance documents a cap of 300 connection attempts per 5 minutes per IP. OKX documents 3 new connection requests per second per IP. The SDKs reconnect with a short delay (`reconnectTimeout`, 500ms by default on these clients). They do not implement truncated exponential backoff, and they do not know about those IP caps. If you open many clients, or you reconnect in a tight loop of your own, you can still trip the venue.

Same story for subscribe and order quotas:

- OKX allows 480 `subscribe`, `unsubscribe`, and `login` requests per connection per hour. That number is not an order limit. Order placement has its own, much higher, cap. Calling 480 "operations" will make you throttle trades that the venue would have accepted.
- The clients will not slow those calls down for you.

Bybit REST responses can include `rateLimitApi`, parsed from response headers, per account and per endpoint. Read it if you want. Nothing in the client waits when the remaining budget gets low.

[Exchange state](/ai/exchange-state) goes further on reconnect state. The short version for these packages: topics you passed to `subscribe` / `subscribeV5` are stored, and they are sent again after `reconnected`. Topics you never registered that way will not come back.

## Placing orders on the same socket {#placing-orders-on-the-same-socket}

Several venues accept orders on a WebSocket, and the matching reply comes back on that same connection. The awkward version is an event handler that hunts through every frame for your id. `WebsocketAPIClient` hides that. You `await` a method. The client attaches an id, and resolves the Promise when the reply with that id arrives.

The method names are not the exchange opcodes.

- [binance](/sdk/binance/javascript): `submitNewSpotOrder` and `cancelSpotOrder` send `order.place` and `order.cancel` on `wss://ws-api.binance.com:443`.
- [bybit-api](/sdk/bybit/javascript): `submitNewOrder`, `amendOrder`, and `cancelOrder` send `order.create`, `order.amend`, and `order.cancel` on `wss://stream.bybit.com/v5/trade`.
- [okx-api](/sdk/okx/javascript): `submitNewOrder` and `cancelOrder` send `order` and `cancel-order`.
- [@siebly/kraken-api](/sdk/kraken/javascript): `submitSpotOrder` and `cancelSpotOrder` send spot v2 `add_order` and `cancel_order`.
- [coinbase-api](/sdk/coinbase/javascript) has no WebSocket order client. Orders go through REST, for example `CBAdvancedTradeClient.submitOrder`.

Bybit's trade API calls the correlation field `reqId`, and the venue allows up to 36 characters. The SDK does not generate a 36-character UUID. `getNewRequestId()` is an incrementing number, sent as a string. That is enough for the Promise map, which keys off `op` plus `reqId`.

Binance spot order, from the WebSocket API example. Ed25519 keys avoid signing every command. HMAC and RSA still work, and each command is signed.

```ts title="Imported example"
import { WebsocketAPIClient } from "binance";

const wsClient = new WebsocketAPIClient({
  api_key: process.env.API_KEY,
  api_secret: process.env.API_SECRET,
});

const response = await wsClient.submitNewSpotOrder({
  symbol: "BTCUSDT",
  side: "SELL",
  type: "LIMIT",
  timeInForce: "GTC",
  price: "50000",
  quantity: "0.001",
});
```

Bybit linear order. The client connects and authenticates on first use. Demo trading can consume events, but it does not support this trade socket.

```ts title="Imported example"
import { WebsocketAPIClient } from "bybit-api";

const wsClient = new WebsocketAPIClient({
  key: process.env.API_KEY,
  secret: process.env.API_SECRET,
});

const response = await wsClient.submitNewOrder({
  category: "linear",
  symbol: "BTCUSDT",
  orderType: "Limit",
  qty: "0.001",
  side: "Buy",
  price: "50000",
});
```

OKX needs the passphrase as well. The type marks `instId` as deprecated (March 2026) in favor of `instIdCode` when you have the numeric code. The current examples still pass `instId`, and that field is still accepted.

```ts title="Imported example"
import { WebsocketAPIClient } from "okx-api";

const wsClient = new WebsocketAPIClient({
  accounts: [
{
apiKey: process.env.API_KEY || "",
apiSecret: process.env.API_SECRET || "",
apiPass: process.env.API_PASSPHRASE || "",
},
  ],
});

const response = await wsClient.submitNewOrder({
  instId: "BTC-USDT",
  tdMode: "cash",
  side: "buy",
  ordType: "limit",
  px: "50000",
  sz: "0.001",
});
```

Kraken spot. The client fetches and refreshes the private WebSocket token. You do not call `getWebSocketsToken()` yourself.

```ts title="Imported example"
import { WebsocketAPIClient } from "@siebly/kraken-api";

const wsClient = new WebsocketAPIClient({
  apiKey: process.env.API_SPOT_KEY || "",
  apiSecret: process.env.API_SPOT_SECRET || "",
});

const response = await wsClient.submitSpotOrder({
  order_type: "limit",
  side: "buy",
  limit_price: 50000,
  order_qty: 0.001,
  symbol: "BTC/USD",
});
```

Coinbase is the exception. `WebsocketClient.sendWSAPIRequest()` is marked "Not supported by this exchange, do not use" and returns `undefined`. Place, amend, and cancel through the REST clients (`CBAdvancedTradeClient`, `CBExchangeClient`, `CBInternationalClient`, `CBPrimeClient`). Use the WebSocket client for market data and user streams, not for order entry.

Keep ingestion and order entry apart in your own process. A book handler that also submits orders will stall both when the book gets busy. The SDK will happily let you do that on one connection. The exchange will still count every subscribe and every order against the quota for that connection.

More writeups live on the [Siebly.io blog](/blog). The [SDK index](/sdk) lists every package, including the ones not covered here (Bitget, BitMart, Gate.io, HTX, KuCoin).

## Frequently Asked Questions {#frequently-asked-questions}

### Why stream instead of polling REST? {#why-stream-instead-of-polling-rest}

A WebSocket stays open, so you skip a new TLS handshake on every update. Polling spends request weight and still leaves a gap between calls. You do not get a latency number for free. A blocked event loop, or a reconnect, wipes out whatever the socket saved you.

### How do I keep the Node.js event loop moving? {#how-do-i-keep-the-node-js-event-loop-moving}

`JSON.parse()` already runs on the main thread inside the client. Do not add sorting, database writes, or book rebuilds inside the `update` / `formattedMessage` / `message` handler. Push the payload to a queue or a worker if the work is heavy. Heartbeat timers live on that same loop. If you block it, the pong goes out late and the venue hangs up.

### Do these SDKs rate-limit for me? {#do-these-sdks-rate-limit-for-me}

No. [bybit-api](/sdk/bybit/javascript), [binance](/sdk/binance/javascript), and [okx-api](/sdk/okx/javascript) sign requests and reconnect. They do not hold messages back to stay under a quota. Bybit REST replies may include `rateLimitApi` from the response headers. You still decide what to do with it. Build your own token bucket or queue if you are close to the published caps.

### What is the difference between a protocol ping and a JSON heartbeat? {#what-is-the-difference-between-a-protocol-ping-and-a-json-heartbeat}

A protocol ping is a WebSocket control frame. The `ws` library can answer it without your code seeing a payload. Binance and Coinbase use that. A JSON or text heartbeat is a normal message: Bybit's `{"op":"ping"}`, OKX's `ping`, Kraken spot's `{"method":"ping"}`. The matching client sends the one that venue asked for. You do not send a second ping unless you have turned the built-in heartbeat off.

### How do I keep a local order book in sync? {#how-do-i-keep-a-local-order-book-in-sync}

Depends on the stream.

- Binance `subscribeSpotDiffBookDepth`: buffer, then `getOrderBook()` for `lastUpdateId`, drop events that are already inside the snapshot, apply the rest in order, restart on a gap.
- Binance `subscribeSpotPartialBookDepth`: each message is already a snapshot of the top levels.
- Bybit `orderbook.{depth}.{symbol}`: apply the `snapshot`, then `delta`, and resubscribe if `seq` breaks.
- OKX `books`: apply the first full book, then increments, and resubscribe if the checksum fails. `books5` is snapshots only.
- Kraken `book`: the stream sends a snapshot, then updates.

### Can I place orders over the WebSocket? {#can-i-place-orders-over-the-websocket}

On Binance, Bybit, OKX, and Kraken, yes. Use `WebsocketAPIClient` and `await` the method. The client matches the reply to the request id and resolves the Promise. On Coinbase, no. Use the REST client. The WebSocket API helper on [coinbase-api](/sdk/coinbase/javascript) is intentionally empty.

## Related articles

- [Stream Real-Time Crypto Market Data with TypeScript SDKs](/blog/stream-real-time-crypto-market-data-with-typescript-sdks)
- [Real-Time Crypto Market Data API: Node.js Guide 2026](/blog/real-time-crypto-market-data-api-nodejs-guide-2026)
- [Public Market Data API for Node.js: A Comprehensive Guide for Developers](/blog/public-market-data-api-for-nodejs-a-comprehensive-guide-for-developers)


## Related Siebly Resources

- [Binance JavaScript SDK](/sdk/binance/javascript)
- [Bybit JavaScript SDK](/sdk/bybit/javascript)
- [OKX JavaScript SDK](/sdk/okx/javascript)
- [Siebly SDK directory](/sdk)
- [Siebly AI Prompt Framework & Skills](/ai)
