← Back to blog

Throttling and Batching High-Frequency Market Data in React

Keeping a React UI responsive under hundreds of websocket messages per second - batching, throttling, memoisation, and deciding what to drop.

Oliver Benns
Oliver Benns

Software engineer · Creator of Hedge UI


Point a React app at a few live crypto streams and the failure mode shows up almost immediately. A single liquid pair on Binance can push an aggregate trade every few milliseconds during a volatile minute, a ticker update once a second, and a depth snapshot ten times a second. Open the order book, the trades tape, and an open-orders table that watches a basket of products, and you are suddenly fielding hundreds of websocket messages per second. If each one naively calls setState, React will try to render hundreds of times per second, and the main thread never gets a moment to breathe.

The interesting part is that you almost never need that many renders. The eye cannot read a trades tape that scrolls 200 rows a second, and a price that updates faster than the screen refreshes is wasted work by definition. So the real job is not "make rendering faster" - it is "decide which messages deserve a render at all, and discard the rest cheaply." This post walks through how the Hedge UI demo does that across four panels, the four levers we reach for (coalesce, batch, throttle, memoise), and how to choose between them.

Where the messages come from

Everything in the demo flows through a single websocket connection managed by react-use-websocket 4.13.0, wired up in src/binance/binance-stream.ts. We connect to wss://stream.binance.com (or wss://stream.binance.us:9443 for US clients) and multiplex every panel's subscriptions over that one socket. The three streams that matter for this post:

StreamName patternNative rate
Order book${symbol}@depth10@100ms10/sec (server coalesced)
Trades${symbol}@aggTradeper trade, bursty
Ticker${symbol}@ticker~1/sec per symbol

The volume problem is concentrated in two places. The trades stream is bursty and unbounded - a busy product fires aggTrade events far faster than once per frame. And the open-orders table subscribes to a ticker stream for every distinct product in the book, so even at one update per second per symbol, a basket of seven products is seven independent state-change sources fighting for the same render budget. Each lever below targets a different one of these pressures.

If you are still deciding between transports before you get here, the trade-offs in SSE versus WebSockets for market data are worth reading first - everything below assumes you have already committed to a duplex websocket feed.

Lever 1: let the server coalesce

The cheapest message to render is the one the exchange never sent you. Binance's partial book depth stream comes in a @100ms variant, and the order book panel subscribes to exactly that:

const streamNames = useMemo( () => (productId ? [`${productId.toLowerCase()}@depth10@100ms`] : []), [productId], );

That @depth10@100ms does two kinds of reduction for us server-side. The depth10 caps each frame at the top 10 bids and 10 asks, so we are not diffing a thousand-level book on the client. The 100ms tells Binance to collapse all the updates inside a 100ms window into a single snapshot before it leaves their edge. We get at most ten messages a second instead of the raw torrent, and crucially each one is already a complete, consistent top-of-book - there is no client-side reassembly, no sequence-gap handling, no maintaining a local book. We just render what arrived.

This is the lever to reach for first whenever your data source offers it, because it saves bandwidth, parsing, and renders all at once. The catch is that coalescing is lossy by design: you cannot reconstruct the individual updates that were merged. For a top-of-book display that does not matter. For anything where you need every tick (a full-depth book you maintain yourself, or a fill-by-fill audit), the server cannot coalesce for you and the work moves to the client. We dig into that reconstruction problem in building a real-time order book in React.

The panel then does one more reduction the server cannot: it only renders as many levels as the container can actually show. A ResizeObserver reports the panel's pixel height, and because every row is a fixed 24px we can compute how many levels fit and slice the data down to them:

const maxNumLevels = useMemo(() => { if (height === 0) { return; } // Subtract space for header (24px) const availableHeight = Math.max(height - 24, 0); const totalLevels = Math.floor(availableHeight / 24); // Split between asks and bids return Math.floor(totalLevels / 2); }, [height]);

On a short panel that can only show six levels per side, there is no point mapping and rendering all ten that arrived. The slice happens in a useMemo keyed on the book and maxNumLevels, so re-slicing only runs when the data or the panel size changes, not on every parent render.

Lever 2: batch on the client

The trades stream has no server-side rate limit to lean on. So the market trades panel batches. In src/features/market-trades/market-trades-panel.tsx, a small useMessageBatch hook accumulates incoming aggTrade messages in a ref and only fires once it has collected batchSize of them:

const batchSize = 5; const useMessageBatch = <T,>( nextMessage: T | undefined, onNextBatch: (batch: T[]) => void, ) => { const cache = useRef<T[]>([]); const reset = useCallback(() => { cache.current = []; }, []); useEffect(() => { if (!nextMessage) { return; } cache.current.push(nextMessage); if (cache.current.length < batchSize) { return; } const batchToProcess = cache.current; onNextBatch(batchToProcess); cache.current = []; }, [nextMessage, onNextBatch]); return useMemo(() => ({ reset }), [reset]); };

The win is that four out of every five messages cost a ref push and nothing else. No setState, no render, no reconciliation. Only the fifth message triggers the state update, and it commits all five trades in one pass. The reducer that receives the batch dedupes against trades already on screen and keeps only the 20 most recent:

const newTrades = [...batchTradesNotPresent.reverse(), ...prev]; return newTrades.slice(0, 20);

A couple of honest caveats live in the code as comments, because batching by count rather than by time has a real downside. On an illiquid product where trades trickle in, you might wait a long while for the fifth trade before anything appears - the data can feel stale. The comment in useMarketTrades says as much: "This could cause slowness of data for illiquid products. Could improve by throttle." Count-based batching optimises for the busy case at the cost of the quiet one. If your products are uniformly quiet, a time-based flush is the better tool. There is also a deliberately accepted race: if the batch fires while the initial REST snapshot is still loading, those trades are dropped rather than reconciled, which is fine for a demo tape but worth knowing about.

One detail that is easy to get wrong: the batch resets when the product changes (useEffect(reset, [reset, productId])). Without that, switching from BTC to ETH would carry a half-full cache of BTC trades into the ETH tape.

Lever 3: throttle low-value updates

Batching suits a stream where every message is a distinct event you want to keep. Throttling suits a stream where only the latest value matters and the ones in between are disposable. The open-orders panel is the textbook case. It watches a ticker stream per product to show a live mark price next to each order, and the current price from three updates ago is simply not interesting once a newer one has arrived.

So in src/features/open-orders/open-orders-panel.tsx the merged ticker map is passed through a 1000ms throttle before it ever reaches the render path:

const tickerStreams = useTickerStreams(streamNames); const throttledTickerStreams = useThrottledValue(tickerStreams, 1000);

The hook itself is leading-and-trailing: it fires immediately if enough time has passed since the last update, otherwise it schedules a single trailing call so the final value in a quiet patch is never lost.

const useThrottledValue = <T,>(value: T, delay: number): T => { const [throttledValue, setThrottledValue] = useState(value); const lastExecuted = useRef<number>(Date.now()); const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); useEffect(() => { const now = Date.now(); const timeSinceLast = now - lastExecuted.current; if (timeSinceLast >= delay) { setThrottledValue(value); lastExecuted.current = now; } else { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } timeoutRef.current = setTimeout(() => { setThrottledValue(value); lastExecuted.current = Date.now(); }, delay - timeSinceLast); } return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, [value, delay]); return throttledValue; };

The reason this matters so much for open orders is the fan-out. The table watches a basket of up to seven distinct products, each with its own once-a-second ticker. Without the throttle, those updates arrive interleaved and each one re-runs the row-building useMemo and re-renders the table. The 1000ms throttle collapses the basket down to at most one table render per second regardless of how many symbols are in it. A mark price that lags real-time by under a second is invisible to a human scanning an orders table, so we pay nothing the user can perceive. Picking that number is a budgeting exercise; the framing in latency budgets for trading UIs is how I reason about which surfaces can afford a one-second lag and which cannot.

A note on throttle versus debounce, because they get conflated. Debounce waits for quiet - it only fires after updates stop for the delay window. For a stream that never goes quiet, a debounce would fire never. That makes debounce the wrong choice for a continuous price feed and the right choice for something like a search input or a resize handler. Throttle guarantees a steady cadence under sustained load, which is exactly what a live feed needs.

Lever 4: memoise to stop the cascade

The first three levers cut down how often state changes. The fourth makes sure that when state does change, the re-render does not spread further than it has to. React re-renders a component when its parent renders, even if its own props are identical, so a single state update high in a panel tree can cascade into expensive children that had no reason to redraw.

Every panel in the demo wraps its presentational component in React.memo and feeds it referentially stable props. The market trades panel is the clearest illustration of why both halves are needed:

const MemoizedMarketTrades = memo(MarketTrades); export const MarketTradesPanel = () => { const { product } = useProduct(); const { trades, isLoading } = useMarketTrades(product?.id); // Allow React.memo to work correctly. If we put this down on the return line, // a new array will cause a re-render each time, not memoizing empty. const memoizedTrades = useMemo(() => { return trades ?? []; }, [trades]); // ... return ( <MemoizedMarketTrades trades={memoizedTrades} base={product.base} quote={product.quote} isLoading={isLoading} /> ); };

That comment is load-bearing. memo only skips a render when the new props are referentially equal to the old ones. If the fallback had been written inline as trades ?? [] on the JSX, every parent render would allocate a fresh [], memo's shallow comparison would see a new array reference, and the memo would be defeated on every pass. Hoisting the fallback into a useMemo keyed on trades means the empty array keeps the same identity until trades itself actually changes. Memoisation in React is a chain: one unstable prop anywhere in the chain breaks every link below it.

This is the most common own-goal I see in streaming UIs - a React.memo wrapper that does nothing because an upstream callback or array is reallocated each render. Note the callbacks the panels hand down (onRowClick, onSort, onPriceClick) are all wrapped in useCallback for the same reason: a fresh function identity defeats the memo just as surely as a fresh array does. The broader catalogue of these patterns, and how to audit a tree for broken memo boundaries, is in performant React trading applications.

Lever 5: cache the latest, drop the intermediate

The connection layer adds one more reduction that sits underneath everything else. useStreamCache keeps only the most recent message per stream name in a record keyed by stream:

setLastMessages((prev) => ({ ...prev, [lastMessage.stream]: lastMessage.data, }));

For streams where only the newest frame matters - the ticker, the depth snapshot - this means an intermediate message that arrives and is superseded before a consumer reads it is simply overwritten. We never build a queue we have to drain. Paired with the throttle, the open-orders panel reads whatever the latest cached ticker happens to be at each 1000ms tick and is completely indifferent to how many updates landed in between.

There is a correctness companion to this called useLastValidMessage. When you switch products, the socket can still deliver a stray message from the old subscription before the unsubscribe lands. That hook filters any message whose stream name is not in the current subscription set, so a late BTC tick cannot briefly corrupt an ETH view. Dropping frames is only safe when you are also dropping the wrong frames, and that guard is what makes the rest of the dropping safe. The subscription diffing behind it - sending SUBSCRIBE/UNSUBSCRIBE only for the delta of stream names each render rather than tearing down the socket - is covered in websocket state management for crypto trading.

Choosing the right lever

These are not alternatives; the demo uses all of them at once on different streams. The decision is per-stream, and it comes down to two questions: do you need every message, and where does the work get done.

TechniqueWhere it runsKeeps every message?Best forWatch out for
CoalesceServerNo, merges within a windowTop-of-book, anything the source can pre-mergeLossy; only if the source supports it
Batch (by count)ClientYes, in groupsTrade tapes, append-only event logsStale data on slow streams
ThrottleClientNo, latest per intervalContinuous price feeds where only newest mattersPicking the interval; perceptible lag if too long
DebounceClientNo, only the final valueBursty-then-quiet input (search, resize)Fires never on a stream that never quiets
Cache latestClientNo, overwritesPer-key newest value (tickers, snapshots)Loses history you might have wanted

A rough rule: if losing an intermediate value is a bug, batch. If losing an intermediate value is fine and you want a steady cadence, throttle or cache-latest. If you want nothing until activity stops, debounce. And before any of that, ask whether the server will coalesce for you.

The cost of over-rendering

It is worth being concrete about what you are buying, because "fewer renders" sounds abstract until you watch the flame graph. An unthrottled basket of seven tickers at one update per second each is seven renders per second of a table that may carry a hundred rows, each render re-running the row useMemo, re-sorting, and reconciling the DOM. The throttle takes that to one render per second. The trades batch turns a 200-message burst into 40 renders instead of 200. None of these changes the data the user sees in any way they could notice, and together they free up most of a frame budget that the order book and the price chart actually need.

There is also a quieter cost to over-rendering: it competes with input. A main thread pegged at rebuilding tables cannot respond to a click on the order book to populate the order form, and that is the interaction the user actually came for. Cutting renders is as much about input latency as it is about smooth scrolling. The same compositing logic is why the depth bars use a CSS transform: scale3d(width, 1, 1) rather than animating width - it keeps the bar updates on the GPU and off the layout path entirely.

Measure before you reach for any of this

The order in which I added these levers is not the order you should add yours. The demo is tuned because I watched it misbehave first. Open the React DevTools profiler, turn on "highlight updates," and switch to a liquid product. If the trades tape is strobing and the open-orders table is flashing on every tick, you have found your candidates. If they are not, do not add a batch or a throttle on principle - every one of these introduces lag, complexity, and an edge case (the illiquid-product staleness, the trailing-edge timer, the broken memo) that you now own.

Concretely, the sequence I would follow on a new panel:

  1. Ship it naive, with a direct setState per message.
  2. Profile under the busiest realistic product, not a quiet one.
  3. If a panel is the bottleneck, ask first whether the server can coalesce. That is free.
  4. For append-only streams that need every event, batch. For latest-value streams, throttle or cache-latest.
  5. Only after the update frequency is under control, wrap in React.memo and chase down every unstable prop - and verify in the profiler that the memo actually holds.

Most of lever 4 gets easier as the tooling improves: the React Compiler removes a lot of the manual useMemo and memo plumbing that the memoisation step depends on, which shifts where you spend effort. But none of that touches the first three levers - the compiler will not decide for you whether a stream should be batched, throttled, or left alone, because that is a product judgement about which messages matter, not a mechanical one. That part stays in your hands.

Whatever you build, pin the behaviour down with tests that simulate message bursts, the way testing real-time React components lays out, so a future refactor cannot quietly reintroduce the render storm you just fixed. A test that fires 200 synthetic trades and asserts a bounded render count is the only thing that keeps these optimisations from rotting the next time someone touches the panel. The levers themselves are simple. Knowing which one a given stream needs, and proving it with a profiler before and after, is the actual skill.

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