Blog
WebSocketsTrading systemsTypeScriptNode.jsJavaScript

Deduplicating WebSocket Messages in Node.js

Deduplicating exchange WebSocket messages in Node.js, with examples of trade IDs, repeated order updates, bounded caches, and database writes.

Siebly.io9 min readMarkdown

Duplicate Trades

A trade recorder receives a trade over WebSocket and saves it. After a disconnect, a history request returns the same trade again. Inserting both copies doubles its contribution to volume, even though the exchange only executed it once.

The duplicate can come from overlapping history pages, two active subscriptions, or a retry after a database timeout. A reconnect doesn't necessarily replay old messages. Often the overlap comes from the application's own recovery work, described in WebSocket Reconnects and Backfill.

For an individual trade, a stable trade ID gives the recorder something to compare. Order updates need different treatment: the same order ID appears when an order opens, partially fills, and finishes. Discarding everything after the first message would leave the order permanently open in the cache.

Choosing Event Keys

Consider these simplified trade records. The IDs are illustrative, and all three deliveries belong to the same exchange, market, and symbol.

DeliveryTrade IDPriceQuantityResult
Live messagetrade-A650000.001Store
Next live messagetrade-B650000.001Store
History responsetrade-A650000.001Already stored

trade-A and trade-B happen to have identical prices and quantities. They are still separate trades. A price-and-quantity key would lose trade-B; adding a timestamp doesn't fix this when multiple trades share that timestamp.

The ID's meaning depends on the stream. Bybit public trades carry the trade ID in i. Private execution messages carry an execId for each execution, and one message can contain several executions for the same order. The loop needs to process each record in data.

Binance Spot uses t for an individual trade and a for an aggregate trade. Those are different record types. Comparing their numeric IDs or matching their prices won't merge the streams correctly.

The stored key includes the exchange, environment, product, record type, symbol, and record ID. Private executions also need an account scope. REST and WebSocket copies of the same record must produce the same key, so the transport name and a newly created socket ID don't belong in it. Adding the connection ID would make an old trade look new after every reconnect.

Keep textual IDs as strings. If an API sends numeric IDs beyond JavaScript's safe integer range, the JSON parser must preserve them before they become a Number. Calling String() on an already rounded number cannot restore the missing digits. A record with no usable ID needs an explicit error or separate handling; a payload hash can't establish an identity the schema doesn't provide.

Updating an Existing Order

An order cache uses the order ID to find the row to update. That doesn't make the order ID a suitable key for discarding subsequent events. For a two-unit order, these messages all describe useful progress:

Order IDStatusCumulative filled quantity
order-ANew0
order-APartiallyFilled1
order-APartiallyFilled1.5
order-AFilled2

A key of orderId + orderStatus still loses the second partial fill. The status hasn't changed, but the filled quantity has. The cache accepts each valid update and stores the reported cumulative quantity. Adding that cumulative quantity to the previous total would count earlier fills again.

Bybit documents a cancel race that can produce two Filled updates for one order. One reports the fill; the other reports that cancellation failed because the order had filled. The cached filled quantity remains 2, while the cancellation result can still be useful. An individual execution record has its own ID for fee and fill accounting. Deduplication also doesn't establish ordering: a delayed New update mustn't reopen a filled order. Accepting or rejecting an update still follows the channel's ordering rules and the order's current state.

A command response is separate again. A successful order-placement request doesn't mean the order has filled, and its request ID doesn't identify a later execution. Recovering Account State covers order progress and requests whose outcome is unknown.

The same issue appears in candle feeds. Bybit kline updates share a candle's start while its prices and volume change; confirm becomes true when it closes. Keeping the first update and discarding the rest freezes the candle before it is complete.

Deduplication in Memory

A single-process counter can keep recently accepted trade IDs in a Set. The example below uses the i and s fields from Bybit public trades. Its scope is fixed to Bybit mainnet spot trades, and its only effect is incrementing an in-memory counter.

Save it as deduplicate.mjs and run node deduplicate.mjs. The sample records are synthetic and require no exchange connection.

deduplicate.mjs

JavaScript
export function createTradeCounter(maxKeys = 10_000) {
  if (!Number.isSafeInteger(maxKeys) || maxKeys < 1) {
    throw new Error('maxKeys must be a positive safe integer');
  }

  const seen = new Set();
  let count = 0;

  return {
    accept(trade) {
      if (typeof trade?.i !== 'string' || !trade.i ||
          typeof trade.s !== 'string' || !trade.s) {
        throw new Error('Trade requires a string ID and symbol');
      }

      const key = JSON.stringify([
        'bybit', 'mainnet', 'spot', 'trade', trade.s, trade.i,
      ]);
      if (seen.has(key)) return 'duplicate';

      seen.add(key);
      count += 1;
      if (seen.size > maxKeys) {
        seen.delete(seen.values().next().value);
      }
      return 'accepted';
    },
    get count() {
      return count;
    },
  };
}

const counter = createTradeCounter();
for (const i of ['trade-A', 'trade-B', 'trade-A']) {
  console.log(i, counter.accept({ i, s: 'BTCUSDT' }));
}
console.log('Count:', counter.count);

The output is:

Output

Text
trade-A accepted
trade-B accepted
trade-A duplicate
Count: 2

The key is a serialized array of explicit fields. JSON encoding keeps the field boundaries intact without relying on a separator that might appear inside an ID. It isn't a hash of the payload.

The lookup, insertion, and counter update are synchronous. Another callback in this process can't enter halfway through them. Adding await saveTrade(trade) between the lookup and insertion changes that: a second handler can find the key absent while the first write is still pending.

Moving seen.add() before the await closes that race but creates another problem. If the write fails, the next attempt finds the key and skips a trade that was never saved. The database example below handles this at the write itself.

Cache Retention

The counter remembers at most 10,000 keys, evicting the oldest accepted key first. That number is an example memory limit, not a guarantee about how far an exchange can replay. With a limit of two, receiving A, B, C, then A accepts all four: C has already evicted the first A.

Restarting the process forgets every key. A time-based expiry has the same limitation for events older than its retention window. A chart that rebuilds from a snapshot may tolerate that; a permanent trade archive cannot rely on this cache to prevent duplicate rows.

Connecting the Trade Handler

The Bybit JavaScript SDK delivers public trade batches on update. To use the counter with live data, replace everything after the function in deduplicate.mjs with this code, and install the package with npm install bybit-api:

Live Bybit spot trades

JavaScript
const { WebsocketClient } = await import('bybit-api');
const counter = createTradeCounter();
const ws = new WebsocketClient();

ws.on('update', (message) => {
  if (message.topic !== 'publicTrade.BTCUSDT') return;

  for (const trade of message.data) {
    const result = counter.accept(trade);
    if (result === 'accepted') {
      console.log(trade.i, trade.p, trade.v);
    }
  }
});

ws.subscribeV5('publicTrade.BTCUSDT', 'spot');

The handler is attached before subscription. Its counter survives reconnects for as long as the Node.js process stays alive. The SDK manages the connection and subscription; the counter decides which trade IDs it has seen. This example validates the fields needed for the key, not the complete exchange payload.

Only public spot data is subscribed here. A derivatives feed, testnet connection, or private execution stream needs its own scope and field mapping. The counter is still just an in-memory example; console output doesn't make it a trade archive.

If recovery also fetches REST history, request pacing is separate from duplicate detection. The backfill example covers pagination and request limits.

Saving Trades Once

Two Node.js workers each have their own Set, so both can accept the same trade. A shared cache can coordinate them, but it still leaves a gap between claiming a key and writing the record. When the intended effect is a database insert, a unique constraint can handle the duplicate at that point.

This PostgreSQL table stores individual public trades. Its primary key uses the same fields across live delivery and history imports. Price and quantity use numeric so they can be passed as decimal strings without a JavaScript floating-point conversion.

Trade storage

sql
CREATE TABLE recorded_trades (
  exchange text NOT NULL,
  environment text NOT NULL,
  category text NOT NULL,
  record_type text NOT NULL,
  symbol text NOT NULL,
  trade_id text NOT NULL,
  price numeric NOT NULL,
  quantity numeric NOT NULL,
  PRIMARY KEY (
    exchange, environment, category, record_type, symbol, trade_id
  )
);

The parameterized insert uses those columns as its conflict target:

Insert or skip a duplicate

sql
INSERT INTO recorded_trades (
  exchange, environment, category, record_type,
  symbol, trade_id, price, quantity
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (
  exchange, environment, category, record_type, symbol, trade_id
) DO NOTHING
RETURNING trade_id;

PostgreSQL returns a row only when this statement inserts one. A duplicate returns no rows. Two workers can attempt the same insert without first racing through separate SELECT and INSERT calls.

If the connection drops after the insert commits but before the response arrives, retrying the insert finds the stored trade. If it never committed, the retry can insert it. This assumes a trade record is immutable under that ID. A feed that publishes corrections needs an update policy; blindly ignoring a changed payload would hide the correction.

Suppose the handler also adds each new trade's quantity to a stored volume total. Writing the trade and total separately leaves room for a crash between them. On restart, the duplicate insert is skipped, but the total is still missing that trade.

A transaction can insert the trade and increment the total together. The increment runs only if the insert returned a row, and both changes commit or roll back together. The same approach applies to a saved backfill cursor. A process-local Set can reduce redundant database calls, but it cannot replace that transaction.

An HTTP request or notification cannot join a normal database transaction. Recording pending work in the same transaction prevents the work from disappearing between the commit and the external call. A worker can send it later, but a crash after sending and before recording success still causes a retry. The receiver needs an idempotency key or another way to recognise the repeated operation. Deduplicating the incoming trade alone doesn't prevent that second side effect.

A high duplicate count is also worth investigating. Repeated history boundaries are expected; two unintended live subscriptions are a connection-management bug. Recording the feed scope, duplicate count, and cache evictions helps distinguish them without logging every payload. Missing IDs and failed writes should remain errors rather than being counted as duplicates.

A small replay check is enough to start: send the same trade twice, then a different trade with the same price, and confirm that two trades are stored. For a database-backed handler, also check a retry after a failed write and a process restart; no live orders are needed.

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.