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
bookupdates 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.
What you will build
For this tutorial, the app will subscribe to these symbols:
GATEIO_SPOT_LINK_USDTBINANCE_SPOT_LINK_USDTOKEX_SPOT_LINK_USDTBYBITSPOT_SPOT_LINK_USDT
You can replace them later with other ERC-20 USDT symbols if needed.
How book messages are handled
Each incoming book message belongs to one symbol_id.
There are two message types to handle:
Snapshot message
When is_snapshot is true:
- clear the stored bids and asks for that symbol
- insert all levels from the message
- store the sequence number
Incremental update
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
sizeis0 - store the new sequence number
This lets the application maintain the latest book state for each symbol over time.
Prerequisites
You need:
- Node.js installed
- a CoinAPI API key
- terminal access
This tutorial uses the Market Data WebSocket endpoint:
1wss://ws.coinapi.io/v1/Step 1 — Create the project folder
Open your terminal and run:
1mkdir multi-exchange-orderbook-demo
2cd multi-exchange-orderbook-demoStep 2 — Create package.json
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.
Step 3 — Install dependencies
Run:
1npm installThis installs:
wsfor the WebSocket clientdotenvfor loading your API key from a.envfile
Step 4 — Create .env
Create a new file named .env and paste this into it:
1COINAPI_KEY=YOUR_API_KEY_HEREReplace YOUR_API_KEY_HERE with your actual API key, then save the file.
Step 5 — Create index.js
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.
Step 6 — Run the script
Run:
1node index.jsStep 7 — What happens when the script runs
The script will:
- connect to the Market Data WebSocket
- subscribe to the configured symbols
- receive
bookmessages in real time - rebuild the latest order book for each symbol in memory
- 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.
Step 8 — Sample terminal output
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.Step 9 — Where the data comes from
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.
Step 10 — Where the data is saved
There are two outputs:
Terminal output
The latest view is printed in the terminal continuously.
Optional file output
If SAVE_SNAPSHOTS_TO_FILE is set to true, the script also saves snapshots to:
1orderbook_snapshots.jsonlEach 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;Step 11 — How to stop the script
The script runs continuously because it listens to a live WebSocket stream.
To stop it, press:
1CTRL+ CStep 13 — Change the symbols later
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.
Step 14 — Change which exchanges are included in aggregation
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.
Step 15 — Troubleshooting
Missing API key
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
No book output appears
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.
A side of the book looks empty
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.
Old values overwrite new ones
This script avoids that by checking the sequence number and ignoring older or duplicate messages.
See It in Action
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.
Note: For demonstration purposes, the video uses 2 symbols only to keep updates readable.
Congratulations!
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
bookupdates 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.