← Back to blog

Building a Real-Time Order Book in React: A Deep Dive

How to model, merge, and render a live order book from a WebSocket feed - covering data normalisation, price level aggregation, and rendering at 60fps.

Oliver Benns
Oliver Benns

Software engineer · Creator of Hedge UI


The order book is the centrepiece of any trading application. It shows live supply and demand across price levels, updating dozens of times per second. Getting it wrong means laggy UIs, stale prices, and frustrated traders.

This guide walks through the full pipeline: receiving WebSocket messages, merging deltas into state, and rendering the result without dropping frames.

The data model

An order book is two sorted lists - bids (descending by price) and asks (ascending by price). Each entry has a price level and a quantity. Exchange feeds typically send an initial snapshot followed by incremental deltas.

interface PriceLevel { price: number; quantity: number; } interface OrderBook { bids: PriceLevel[]; asks: PriceLevel[]; lastUpdateId: number; }

The key insight is that deltas are upserts: a quantity of zero means "remove this level", any other value replaces the previous quantity at that price.

Receiving and buffering updates

Never pipe raw WebSocket messages directly into React state. A liquid instrument can produce hundreds of updates per second. Instead, buffer updates and flush on a controlled interval.

const bufferRef = useRef<PriceLevel[]>([]); useEffect(() => { const ws = new WebSocket(url); ws.onmessage = (event) => { const delta = JSON.parse(event.data); bufferRef.current.push(...delta.bids, ...delta.asks); }; const interval = setInterval(() => { if (bufferRef.current.length === 0) return; applyDeltas(bufferRef.current); bufferRef.current = []; }, 60); // ~16fps, sufficient for visual updates return () => { ws.close(); clearInterval(interval); }; }, [url]);

This collapses multiple updates to the same price level into a single state change, dramatically reducing render work.

Where the exchange offers pre-throttled streams, take them. Binance's @depth10@100ms partial book stream delivers one snapshot every 100ms with the top 10 levels per side - we point the demo at that and skip the client-side buffering entirely. On a private exchange feed where you control the protocol, the buffer-and-flush pattern moves back to the client and setInterval or requestAnimationFrame plays the role Binance's server plays here.

Merging deltas efficiently

The merge function needs to handle insertions, updates, and deletions in a single pass. Using a Map keyed by price gives O(1) lookups.

function mergeDeltas( existing: Map<number, number>, deltas: PriceLevel[] ): Map<number, number> { const merged = new Map(existing); for (const { price, quantity } of deltas) { if (quantity === 0) { merged.delete(price); } else { merged.set(price, quantity); } } return merged; }

Convert back to a sorted array only at the render boundary - keep the internal representation as a Map for fast updates.

Rendering at 60fps

The visual order book typically shows 15–25 price levels per side. Even if the underlying data has hundreds of levels, you only need to render the top N.

const visibleBids = useMemo( () => Array.from(bids.entries()) .sort(([a], [b]) => b - a) .slice(0, 20) .map(([price, quantity]) => ({ price, quantity })), [bids] );

Wrap each row component in React.memo with a custom comparator that checks price and quantity. This prevents re-rendering rows that did not change in the latest flush.

A useful refinement: the visible level count does not have to be hardcoded. A ResizeObserver measures the panel's height, the panel subtracts the header (24px), divides by row height, and splits the result between bids and asks. The order book is then sliced to that count before it ever reaches the rendering layer. Dragging the panel taller shows more levels, shorter shows fewer - no scrollbars, no clipping. The pattern only works because every row is the same fixed height; it does not generalise to a trade blotter, where you need real virtualisation.

Depth visualisation

The cumulative depth bar is a common visual element. Calculate it as a percentage of the maximum cumulative quantity across visible levels.

const cumulativeBids = visibleBids.reduce<number[]>((acc, level, i) => { acc.push((acc[i - 1] ?? 0) + level.quantity); return acc; }, []); const maxCumulative = Math.max( cumulativeBids[cumulativeBids.length - 1] ?? 0, cumulativeAsks[cumulativeAsks.length - 1] ?? 0 );

Render the bar as a CSS background gradient or an absolutely positioned div. Avoid SVG for this - it is slower to update at high frequencies.

Handling reconnections

WebSocket connections drop. When reconnecting, you must re-request the full snapshot and discard any buffered deltas from before the reconnection - the broader WebSocket lifecycle patterns apply here directly. A sequence number (lastUpdateId) from the exchange feed lets you detect gaps.

if (delta.firstUpdateId > lastUpdateId + 1) { // Gap detected - request fresh snapshot requestSnapshot(); return; }

Performance checklist

  • Buffer WebSocket messages outside React state
  • Flush on a 50–100ms interval, not on every message
  • Store order book as a Map, not a sorted array
  • Only sort and slice for rendering
  • Memoize row components with custom equality checks
  • Use CSS for depth bars, not SVG
  • Handle reconnection and sequence gaps gracefully

The order book is a microcosm of every real-time rendering challenge in a trading app. Get this component right and the same patterns - buffering, merging, selective rendering - apply everywhere else in your application.

Kickstart Your Trading Application

Hedge UI is a React starter kit with production-ready trading components, real-time data handling, and customisable layouts — so you can ship faster.

Get Hedge UI