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.
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, bybit-api, okx-api, @siebly/kraken-api, and coinbase-api. 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
- 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.BTCUSDTis Bybit.btcusdt@tradeis 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.
WebsocketAPIClientmethods return a Promise on Binance, Bybit, OKX, and Kraken. Coinbase orders stay on the REST client.sendWSAPIRequest()on coinbase-api 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
Market data is a long-lived 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
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 client treats that 24 hour drop as a normal reconnect, and it runs its own heartbeat on top (default
pingIntervalof 10 seconds,pongTimeoutof 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 sends{"op":"ping"}for you, every 10 seconds by default. - OKX wants the plain text frame
ping, and replies withpong. If a subscription never lands, or no data is pushed for 30 seconds, they close the socket. okx-api 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 picks the right one from the socket key. - Coinbase Advanced Trade uses protocol ping frames. coinbase-api 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
You can build this on the ws package and the RFC 6455 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 emitreconnect. All of them emitreconnectedwhen 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.
Subscribing to trades and books
Install the package for the venue you are calling:
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. 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
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.
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.
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.
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 walks through the same client.
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'.
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 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.
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.
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
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
subscribeSpotDiffBookDepth is the incremental stream (btcusdt@depth@100ms). Partial depth, shown above, is different. For the diff stream, Binance's own procedure is:
- Subscribe first and buffer events.
- Call
MainClient.getOrderBook(), which hitsGET /api/v3/depth. - Drop buffered events whose final update id
uis less than or equal to the snapshotlastUpdateId. - 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.
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.
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
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
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, andloginrequests 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 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
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:
submitNewSpotOrderandcancelSpotOrdersendorder.placeandorder.cancelonwss://ws-api.binance.com:443. - bybit-api:
submitNewOrder,amendOrder, andcancelOrdersendorder.create,order.amend, andorder.cancelonwss://stream.bybit.com/v5/trade. - okx-api:
submitNewOrderandcancelOrdersendorderandcancel-order. - @siebly/kraken-api:
submitSpotOrderandcancelSpotOrdersend spot v2add_orderandcancel_order. - coinbase-api 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.
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.
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.
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.
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. The SDK index lists every package, including the ones not covered here (Bitget, BitMart, Gate.io, HTX, KuCoin).
Frequently Asked Questions
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?
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?
No. bybit-api, binance, and okx-api 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?
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?
Depends on the stream.
- Binance
subscribeSpotDiffBookDepth: buffer, thengetOrderBook()forlastUpdateId, 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 thesnapshot, thendelta, and resubscribe ifseqbreaks. - OKX
books: apply the first full book, then increments, and resubscribe if the checksum fails.books5is snapshots only. - Kraken
book: the stream sends a snapshot, then updates.
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 is intentionally empty.
Related articles
Continue from here