Real-Time Multi-Exchange Order Book Depth for an ERC-20 Token

This tutorial shows how to build a small Node.js app that streams real-time order book updates for the same ERC-20 token across multiple exchanges using the CoinAPI Market Data WebSocket.

By the end, the app will:

  • connect to the WebSocket feed
  • subscribe to book updates for one token pair across several exchanges
  • maintain the latest order book for each symbol in memory
  • handle both full snapshots and incremental updates
  • display:
    • top of book per exchange
    • aggregated top of book across exchanges
    • aggregated top 5 bid and ask levels

This is useful for demos involving:

  • liquidity comparison across venues
  • synthetic aggregated depth
  • exchange filtering
  • cross-venue monitoring for the same asset

In the stream, some messages provide a full snapshot of the current book, while others contain only updates to specific levels. For example, your GATEIO sample is a snapshot with is_snapshot: true, sequence: 1, and a full set of bid and ask levels for GATEIO_SPOT_LINK_USDT.

For this tutorial, the app will subscribe to these symbols:

  • GATEIO_SPOT_LINK_USDT
  • BINANCE_SPOT_LINK_USDT
  • OKEX_SPOT_LINK_USDT
  • BYBITSPOT_SPOT_LINK_USDT

You can replace them later with other ERC-20 USDT symbols if needed.

Each incoming book message belongs to one symbol_id.

There are two message types to handle:

When is_snapshot is true:

  • clear the stored bids and asks for that symbol
  • insert all levels from the message
  • store the sequence number

When is_snapshot is false:

  • keep the existing stored book
  • update only the price levels present in the message
  • remove a price level if its incoming size is 0
  • store the new sequence number

This lets the application maintain the latest book state for each symbol over time.

You need:

  • Node.js installed
  • a CoinAPI API key
  • terminal access

This tutorial uses the Market Data WebSocket endpoint:

1wss://ws.coinapi.io/v1/

Open your terminal and run:

1mkdir multi-exchange-orderbook-demo
2cd multi-exchange-orderbook-demo

Create a new file named package.json and paste this into it:

1{
2  "name":"multi-exchange-orderbook-demo",
3  "version":"1.0.0",
4  "description":"Real-time multi-exchange order book depth demo using CoinAPI",
5  "main":"index.js",
6  "type":"commonjs",
7  "dependencies": {
8    "dotenv":"^16.4.5",
9    "ws":"^8.18.0"
10  }
11}

Save the file.

Run:

1npm install

This installs:

  • ws for the WebSocket client
  • dotenv for loading your API key from a .env file

Create a new file named .env and paste this into it:

1COINAPI_KEY=YOUR_API_KEY_HERE

Replace YOUR_API_KEY_HERE with your actual API key, then save the file.

Create a new file named index.js and paste the full script below.

1require("dotenv").config();
2const WebSocket = require("ws");
3const fs = require("fs");
4
5const API_KEY = process.env.COINAPI_KEY;
6const WS_URL = "wss://ws.coinapi.io/v1/";
7
8if (!API_KEY) {
9  console.error("Missing COINAPI_KEY in .env file");
10  process.exit(1);
11}
12
13// Symbols to subscribe to.
14const SUBSCRIBED_SYMBOLS = [
15  "GATEIO_SPOT_LINK_USDT",
16  "BINANCE_SPOT_LINK_USDT",
17  "OKEX_SPOT_LINK_USDT",
18  "BYBITSPOT_SPOT_LINK_USDT"
19];
20
21// Exchanges to include in the aggregated output.
22const ENABLED_EXCHANGES = ["GATEIO", "BINANCE", "OKEX", "BYBITSPOT"];
23
24// Optional snapshot logging.
25const SAVE_SNAPSHOTS_TO_FILE = true;
26const SNAPSHOT_FILE = "orderbook_snapshots.jsonl";
27const SNAPSHOT_INTERVAL_MS = 5000;
28
29// One in-memory reconstructed book per symbol_id.
30const booksBySymbol = {};
31
32function getExchangeId(symbolId) {
33  return symbolId.split("_")[0];
34}
35
36function ensureBook(symbolId) {
37  if (!booksBySymbol[symbolId]) {
38    booksBySymbol[symbolId] = {
39      exchange_id: getExchangeId(symbolId),
40      symbol_id: symbolId,
41      sequence: 0,
42      bids: {},
43      asks: {},
44      last_time_exchange: null,
45      last_time_coinapi: null
46    };
47  }
48}
49
50function applyLevels(levels, sideStore) {
51  if (!Array.isArray(levels)) return;
52
53  for (const level of levels) {
54    const price = String(level.price);
55    const size = Number(level.size);
56
57    if (size === 0) {
58      delete sideStore[price];
59    } else {
60      sideStore[price] = size;
61    }
62  }
63}
64
65function processBookMessage(msg) {
66  const symbolId = msg.symbol_id;
67  ensureBook(symbolId);
68
69  const book = booksBySymbol[symbolId];
70
71  // Ignore duplicate or older messages.
72  if (msg.sequence <= book.sequence) {
73    return;
74  }
75
76  // Snapshot messages replace the stored state for this symbol.
77  if (msg.is_snapshot === true) {
78    book.bids = {};
79    book.asks = {};
80  }
81
82  // Apply the incoming changes.
83  applyLevels(msg.bids, book.bids);
84  applyLevels(msg.asks, book.asks);
85
86  book.sequence = msg.sequence;
87  book.last_time_exchange = msg.time_exchange || null;
88  book.last_time_coinapi = msg.time_coinapi || null;
89}
90
91function toSortedBidArray(book) {
92  return Object.entries(book.bids)
93    .map(([price, size]) => ({ price: Number(price), size: Number(size) }))
94    .filter((level) => level.size > 0)
95    .sort((a, b) => b.price - a.price);
96}
97
98function toSortedAskArray(book) {
99  return Object.entries(book.asks)
100    .map(([price, size]) => ({ price: Number(price), size: Number(size) }))
101    .filter((level) => level.size > 0)
102    .sort((a, b) => a.price - b.price);
103}
104
105function getTopOfBook(book) {
106  const bids = toSortedBidArray(book);
107  const asks = toSortedAskArray(book);
108
109  return {
110    best_bid: bids[0] || null,
111    best_ask: asks[0] || null
112  };
113}
114
115function getFilteredBooks() {
116  return Object.values(booksBySymbol).filter((book) =>
117    ENABLED_EXCHANGES.includes(book.exchange_id)
118  );
119}
120
121function aggregateBooks(books) {
122  const aggregatedBids = {};
123  const aggregatedAsks = {};
124
125  for (const book of books) {
126    for (const level of toSortedBidArray(book)) {
127      const key = String(level.price);
128      aggregatedBids[key] = (aggregatedBids[key] || 0) + level.size;
129    }
130
131    for (const level of toSortedAskArray(book)) {
132      const key = String(level.price);
133      aggregatedAsks[key] = (aggregatedAsks[key] || 0) + level.size;
134    }
135  }
136
137  const bids = Object.entries(aggregatedBids)
138    .map(([price, size]) => ({ price: Number(price), size: Number(size) }))
139    .filter((level) => level.size > 0)
140    .sort((a, b) => b.price - a.price);
141
142  const asks = Object.entries(aggregatedAsks)
143    .map(([price, size]) => ({ price: Number(price), size: Number(size) }))
144    .filter((level) => level.size > 0)
145    .sort((a, b) => a.price - b.price);
146
147  return { bids, asks };
148}
149
150function printHeader() {
151  console.log("=== Real-Time Multi-Exchange Order Book Depth Demo ===");
152  console.log("Subscribed symbols:");
153  for (const symbol of SUBSCRIBED_SYMBOLS) {
154    console.log(`- ${symbol}`);
155  }
156}
157
158function printPerExchangeTopOfBook(books) {
159  console.log("\n=== PER EXCHANGE TOP OF BOOK ===");
160
161  if (books.length === 0) {
162    console.log("No book data received yet.");
163    return;
164  }
165
166  for (const book of books) {
167    const top = getTopOfBook(book);
168
169    const bidText = top.best_bid
170      ? `${top.best_bid.price} (${top.best_bid.size})`
171      : "-";
172
173    const askText = top.best_ask
174      ? `${top.best_ask.price} (${top.best_ask.size})`
175      : "-";
176
177    console.log(`${book.exchange_id} | Bid: ${bidText} | Ask: ${askText}`);
178  }
179}
180
181function printAggregatedTopOfBook(aggregated) {
182  console.log("\n=== AGGREGATED TOP OF BOOK ===");
183  console.log("Best Bid:", aggregated.bids[0] || null);
184  console.log("Best Ask:", aggregated.asks[0] || null);
185}
186
187function printDepth(levels, label, count = 5) {
188  console.log(`\n=== ${label} ===`);
189
190  if (levels.length === 0) {
191    console.log("No levels available.");
192    return;
193  }
194
195  for (const level of levels.slice(0, count)) {
196    console.log(`${level.price} | ${level.size}`);
197  }
198}
199
200function getLatestCoinapiTime(books) {
201  const times = books
202    .map((book) => book.last_time_coinapi)
203    .filter(Boolean)
204    .sort();
205
206  return times.length > 0 ? times[times.length - 1] : null;
207}
208
209function printLastUpdateTime(books) {
210  const latest = getLatestCoinapiTime(books);
211  if (!latest) return;
212
213  console.log(`\nLast CoinAPI update time: ${latest}`);
214}
215
216function saveSnapshotToFile() {
217  const filteredBooks = getFilteredBooks();
218  if (filteredBooks.length === 0) return;
219
220  const aggregated = aggregateBooks(filteredBooks);
221
222  const snapshot = {
223    saved_at: new Date().toISOString(),
224    books: filteredBooks.map((book) => ({
225      exchange_id: book.exchange_id,
226      symbol_id: book.symbol_id,
227      sequence: book.sequence,
228      last_time_exchange: book.last_time_exchange,
229      last_time_coinapi: book.last_time_coinapi,
230      top_of_book: getTopOfBook(book)
231    })),
232    aggregated_top_of_book: {
233      best_bid: aggregated.bids[0] || null,
234      best_ask: aggregated.asks[0] || null
235    },
236    aggregated_top_5_bids: aggregated.bids.slice(0, 5),
237    aggregated_top_5_asks: aggregated.asks.slice(0, 5)
238  };
239
240  fs.appendFileSync(SNAPSHOT_FILE, JSON.stringify(snapshot) + "\n");
241}
242
243function render() {
244  const filteredBooks = getFilteredBooks();
245  const aggregated = aggregateBooks(filteredBooks);
246
247  console.clear();
248  printHeader();
249  printPerExchangeTopOfBook(filteredBooks);
250  printAggregatedTopOfBook(aggregated);
251  printDepth(aggregated.bids, "AGGREGATED TOP 5 BIDS", 5);
252  printDepth(aggregated.asks, "AGGREGATED TOP 5 ASKS", 5);
253  printLastUpdateTime(filteredBooks);
254  console.log("\nPress CTRL + C to stop.");
255}
256
257if (SAVE_SNAPSHOTS_TO_FILE) {
258  setInterval(() => {
259    saveSnapshotToFile();
260  }, SNAPSHOT_INTERVAL_MS);
261}
262
263const ws = new WebSocket(WS_URL);
264
265ws.on("open", () => {
266  console.log("Connected to CoinAPI WebSocket");
267
268  const helloMessage = {
269    type: "hello",
270    apikey: API_KEY,
271    subscribe_data_type: ["book"],
272    subscribe_filter_symbol_id: SUBSCRIBED_SYMBOLS
273  };
274
275  ws.send(JSON.stringify(helloMessage));
276  console.log("Subscription sent");
277  console.log("Waiting for live book messages...");
278});
279
280ws.on("message", (raw) => {
281  try {
282    const msg = JSON.parse(raw.toString());
283
284    if (msg.type !== "book") {
285      return;
286    }
287
288    processBookMessage(msg);
289    render();
290  } catch (error) {
291    console.error("Failed to parse incoming message:", error.message);
292  }
293});
294
295ws.on("error", (error) => {
296  console.error("WebSocket error:", error.message);
297});
298
299ws.on("close", () => {
300  console.log("WebSocket connection closed");
301});

Save the file.

Run:

1node index.js

The script will:

  1. connect to the Market Data WebSocket
  2. subscribe to the configured symbols
  3. receive book messages in real time
  4. rebuild the latest order book for each symbol in memory
  5. print:
    • top of book per exchange
    • aggregated best bid and best ask
    • aggregated top 5 bids and asks

If file logging is enabled, it will also append periodic snapshots to orderbook_snapshots.jsonl.

Because this is live data, your output will differ. A sample output might look like this:

1=== Real-Time Multi-Exchange Order Book Depth Demo ===
2Subscribed symbols:
3- GATEIO_SPOT_LINK_USDT
4- BINANCE_SPOT_LINK_USDT
5- OKEX_SPOT_LINK_USDT
6- BYBITSPOT_SPOT_LINK_USDT
7
8=== PER EXCHANGE TOP OF BOOK ===
9GATEIO | Bid: 8.48 (14.2) | Ask: 8.52 (18.7)
10BINANCE | Bid: 8.49 (3250.44) | Ask: 8.51 (2811.03)
11OKEX | Bid: 8.47 (422.18) | Ask: 8.53 (390.26)
12BYBITSPOT | Bid: 8.48 (611.55) | Ask: 8.52 (704.31)
13
14=== AGGREGATED TOP OF BOOK ===
15Best Bid: { price: 8.49, size: 3250.44 }
16Best Ask: { price: 8.51, size: 2811.03 }
17
18=== AGGREGATED TOP 5 BIDS ===
198.49 | 3250.44
208.48 | 625.75
218.47 | 422.18
228.46 | 980.5
238.45 | 150.2
24
25=== AGGREGATED TOP 5 ASKS ===
268.51 | 2811.03
278.52 | 723.01
288.53 | 390.26
298.54 | 505.6
308.55 | 118.3
31
32Last CoinAPI update time: 2026-03-30T14:00:24.4474047Z
33
34Press CTRL + C to stop.

You do not need to copy JSON into files manually.

The script receives live messages directly from the WebSocket stream in this handler:

1ws.on("message", (raw) => {
2  const msg = JSON.parse(raw.toString());
3  ...
4})

Those messages are processed in memory as they arrive.

There are two outputs:

The latest view is printed in the terminal continuously.

If SAVE_SNAPSHOTS_TO_FILE is set to true, the script also saves snapshots to:

1orderbook_snapshots.jsonl

Each line in that file is a JSON object.

If you do not want file output, change:

1const SAVE_SNAPSHOTS_TO_FILE = true;

to:

1const SAVE_SNAPSHOTS_TO_FILE = false;

The script runs continuously because it listens to a live WebSocket stream.

To stop it, press:

1CTRL+ C

To test a different ERC-20 asset, edit this block in index.js:

1const SUBSCRIBED_SYMBOLS = [
2  "GATEIO_SPOT_LINK_USDT",
3  "BINANCE_SPOT_LINK_USDT",
4  "OKEX_SPOT_LINK_USDT",
5  "BYBITSPOT_SPOT_LINK_USDT"
6];

For example, to use UNI instead:

1const SUBSCRIBED_SYMBOLS = [
2  "GATEIO_SPOT_UNI_USDT",
3  "BINANCE_SPOT_UNI_USDT",
4  "OKEX_SPOT_UNI_USDT",
5  "BYBIT_SPOT_UNI_USDT"
6];

Then save the file and run.

To exclude an exchange from the aggregated output, edit this line:

1const ENABLED_EXCHANGES = ["GATEIO", "BINANCE", "OKEX", "BYBITSPOT"];

For example:

1const ENABLED_EXCHANGES = ["BINANCE", "OKEX", "BYBITSPOT"];

This will keep the script subscribed to all configured symbols, but the aggregated output will only include the exchanges listed here.

If you see:

1Missing COINAPI_KEY in .env 

check that:

  • the file name is .env
  • the key is written as COINAPI_KEY=...
  • the file is saved

Possible reasons:

  • one or more symbol IDs are not active
  • the symbols are not covered in your environment
  • there have not been recent updates yet

Try a different base asset such as UNI or CRV.

This can happen briefly at startup before both sides have been initialized for all symbols. Once snapshot and update messages arrive, the book should fill in.

This script avoids that by checking the sequence number and ignoring older or duplicate messages.

Below is a short example of the script running with two exchanges.

This shows how order book updates arrive in real time and how the aggregated view updates continuously.

👉 Watch the demo here.

Note: For demonstration purposes, the video uses 2 symbols only to keep updates readable.

You now have a working, real-time, multi-exchange order book depth application.

In this tutorial, you were able to:

  • connect to the CoinAPI Market Data WebSocket
  • subscribe to live book updates across multiple exchanges
  • handle both snapshot and incremental messages correctly
  • maintain a continuously updated order book in memory per symbol
  • compute and display top-of-book per exchange
  • build an aggregated view of bid/ask depth across venues
  • optionally persist snapshots for further analysis

This setup reflects how real-world systems consume and process streaming market data. Instead of relying on static responses, your application continuously updates its state based on incoming events, allowing you to monitor liquidity and pricing across exchanges in real time.

From here, you can extend this further by:

  • visualizing depth with charts
  • tracking spread differences across exchanges
  • detecting arbitrage opportunities
  • estimating slippage for different trade sizes
  • storing historical snapshots in a database

You now have a solid foundation for building more advanced market data applications on top of real-time streams.