Development of Web Trading Terminal with Charts and Order Book
A web trading terminal with charts and order book is the most technically demanding type of web application. Simultaneously: real-time data in multiple streams, financial data visualization, high-frequency UI updates, complex business logic. Architectural decisions made at the beginning determine scalability and performance for years to come.
Technology Stack
Frontend: React 18 + TypeScript, Zustand (state management), TradingView Lightweight Charts or Advanced Charts, react-virtual (virtualization), WebSocket via custom hook.
Backend: FastAPI (Python) or Fastify (Node.js) for WebSocket gateway, Redis for caching order book state and pub/sub.
Data Layer: ClickHouse or TimescaleDB for historical OHLCV data.
Data Flow Graph
Binance WS → Exchange Connector → Redis PubSub → WS Gateway → Browser
↘
ClickHouse (persist)
Browser subscribes to Gateway via WebSocket. Gateway multiplexes data from the exchange for all connected clients.
Order Book: Implementation Details
Order book updates via differential updates. Binance provides a snapshot via REST and subsequent diffs via WebSocket:
class OrderBookManager {
private bids: Map<number, number> = new Map();
private asks: Map<number, number> = new Map();
private lastUpdateId: number = 0;
private buffer: OrderBookDiff[] = [];
async initialize(symbol: string) {
// 1. Subscribe to diff stream
const ws = this.connectToStream(`${symbol.toLowerCase()}@depth`);
// 2. Get snapshot (after subscription!)
const snapshot = await fetchOrderBookSnapshot(symbol, 1000);
// 3. Apply snapshot
this.lastUpdateId = snapshot.lastUpdateId;
this.bids = new Map(snapshot.bids.map(([p, q]) => [+p, +q]));
this.asks = new Map(snapshot.asks.map(([p, q]) => [+p, +q]));
// 4. Apply buffered diffs, skipping outdated ones
for (const diff of this.buffer) {
if (diff.U <= this.lastUpdateId + 1 && diff.u >= this.lastUpdateId + 1) {
this.applyDiff(diff);
}
}
this.buffer = [];
}
applyDiff(diff: OrderBookDiff) {
// Check sequence
if (diff.U !== this.lastUpdateId + 1) {
console.error('Gap detected, reinitializing...');
this.initialize(this.symbol);
return;
}
for (const [price, qty] of diff.b) {
if (+qty === 0) this.bids.delete(+price);
else this.bids.set(+price, +qty);
}
for (const [price, qty] of diff.a) {
if (+qty === 0) this.asks.delete(+price);
else this.asks.set(+price, +qty);
}
this.lastUpdateId = diff.u;
this.notifySubscribers();
}
getTopLevels(depth: number = 20) {
const sortedBids = [...this.bids.entries()]
.sort(([a], [b]) => b - a)
.slice(0, depth);
const sortedAsks = [...this.asks.entries()]
.sort(([a], [b]) => a - b)
.slice(0, depth);
return { bids: sortedBids, asks: sortedAsks };
}
}
Order Book Component with Virtualization
import { useVirtualizer } from '@tanstack/react-virtual';
import { useOrderBook } from '../hooks/useOrderBook';
const ORDER_BOOK_ROW_HEIGHT = 20;
export function OrderBook({ symbol }: { symbol: string }) {
const { bids, asks, spread } = useOrderBook(symbol);
const parentRef = React.useRef<HTMLDivElement>(null);
const bidVirtualizer = useVirtualizer({
count: bids.length,
getScrollElement: () => parentRef.current,
estimateSize: () => ORDER_BOOK_ROW_HEIGHT,
overscan: 5,
});
return (
<div className="order-book">
<div className="asks-section">
{asks.slice(0, 20).reverse().map(([price, qty, total]) => (
<OrderBookRow key={price} side="ask" price={price} qty={qty} total={total} />
))}
</div>
<div className="spread-row">
Spread: {spread.toFixed(2)} ({spreadBps.toFixed(2)} bps)
</div>
<div ref={parentRef} className="bids-section">
<div style={{ height: bidVirtualizer.getTotalSize() }}>
{bidVirtualizer.getVirtualItems().map((virtualItem) => {
const [price, qty, total] = bids[virtualItem.index];
return (
<div
key={virtualItem.key}
style={{ transform: `translateY(${virtualItem.start}px)` }}
>
<OrderBookRow side="bid" price={price} qty={qty} total={total} />
</div>
);
})}
</div>
</div>
</div>
);
}
Charts: TradingView Lightweight Charts
For most web terminals, TradingView Lightweight Charts is the optimal choice:
import { createChart, CandlestickSeries } from 'lightweight-charts';
function TradingChart({ symbol }: { symbol: string }) {
const chartContainerRef = useRef<HTMLDivElement>(null);
const seriesRef = useRef<CandlestickSeries>();
useEffect(() => {
const chart = createChart(chartContainerRef.current!, {
width: chartContainerRef.current!.clientWidth,
height: 400,
layout: {
background: { color: '#1a1a2e' },
textColor: '#d1d4dc',
},
grid: {
vertLines: { color: '#2d2d4e' },
horzLines: { color: '#2d2d4e' },
},
crosshair: { mode: 1 },
timeScale: { borderColor: '#485c7b', timeVisible: true },
});
const candleSeries = chart.addCandlestickSeries({
upColor: '#26a69a',
downColor: '#ef5350',
borderUpColor: '#26a69a',
borderDownColor: '#ef5350',
});
seriesRef.current = candleSeries;
// Load historical data
loadHistoricalData(symbol, '1h').then((candles) => {
candleSeries.setData(candles);
chart.timeScale().fitContent();
});
// Subscribe to new candles
const unsubscribe = wsGateway.subscribe(`candle:${symbol}:1h`, (candle) => {
candleSeries.update(candle);
});
return () => {
unsubscribe();
chart.remove();
};
}, [symbol]);
return <div ref={chartContainerRef} className="chart-container" />;
}
Performance
Critical optimization points:
Throttle updates — order book can receive 50+ updates per second. Apply all diffs in memory, but render no more than via requestAnimationFrame:
let pendingRender = false;
function scheduleRender() {
if (!pendingRender) {
pendingRender = true;
requestAnimationFrame(() => {
pendingRender = false;
renderOrderBook();
});
}
}
Immutable updates with memo — React.memo + useMemo for order book rows to avoid unnecessary re-renders.
Canvas instead of DOM for depth chart — cumulative order book volume on Canvas performs significantly faster than DOM for frequent updates.
WebSocket Connection Management
export function useWebSocket(url: string) {
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<NodeJS.Timeout>();
const connect = useCallback(() => {
wsRef.current = new WebSocket(url);
wsRef.current.onclose = (e) => {
if (!e.wasClean) {
// Exponential backoff
const delay = Math.min(1000 * 2 ** reconnectCount, 30000);
reconnectTimerRef.current = setTimeout(connect, delay);
}
};
wsRef.current.onerror = () => wsRef.current?.close();
}, [url]);
useEffect(() => {
connect();
return () => {
clearTimeout(reconnectTimerRef.current);
wsRef.current?.close();
};
}, [connect]);
return wsRef;
}
Correct reconnect with exponential backoff — a mandatory part of a production terminal. User should not see "disconnected" for more than a few seconds.







