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.
| Delivery | Trade ID | Price | Quantity | Result |
|---|---|---|---|---|
| Live message | trade-A | 65000 | 0.001 | Store |
| Next live message | trade-B | 65000 | 0.001 | Store |
| History response | trade-A | 65000 | 0.001 | Already 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 ID | Status | Cumulative filled quantity |
|---|---|---|
order-A | New | 0 |
order-A | PartiallyFilled | 1 |
order-A | PartiallyFilled | 1.5 |
order-A | Filled | 2 |
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
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
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
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.
Related articles
Continue from here