Throttle high-frequency updates
Real-time trading platforms, live price feeds, and collaborative tools can receive hundreds of WebSocket messages per second, but your React components cannot realistically render that many times without freezing the UI. The solution is simple: throttle high-frequency updates by buffering incoming messages and flushing them to state at strategic intervals, letting the browser's natural rendering cycle handle the rest. In this article, I'll walk you through a production pattern I've used across multiple platforms to stay responsive even under message storms.
Understanding the problem
When you connect a WebSocket directly to React state and update on every message, you force the component to re-render hundreds of times per second. Each re-render triggers diffing, layout thrashing, and paint operations. The browser's JavaScript event loop becomes saturated, and even simple interactions like typing in an input or scrolling feel janky.
The core issue is frequency mismatch. WebSocket messages arrive at network speed; the browser refreshes at 60 FPS (roughly 16ms per frame). If you receive 300 messages per second, you're getting 5 messages per frame, but you can only display one state per frame. The extra 4 messages are wasted work.
I learned this the hard way building a high-frequency trading dashboard. A client received 600+ price updates per second from an exchange. Without throttling, the entire UI thread locked up, and users couldn't even close dialogs. After implementing the pattern below, we dropped to 60 renders per second, and the platform became buttery smooth.
Buffer incoming messages
The first step is to stop pushing every message directly into state. Instead, accumulate them in a buffer—a regular JavaScript object or Map—outside of React's rendering cycle.
Here's the pattern I use:
import { useEffect, useRef, useState } from 'react';
interface Price {
symbol: string;
value: number;
timestamp: number;
}
export function PriceTicker() {
const [prices, setPrices] = useState>({});
const bufferRef = useRef>({});
const wsRef = useRef(null);
useEffect(() => {
wsRef.current = new WebSocket('wss://price-feed.example.com');
wsRef.current.onmessage = (event) => {
const price: Price = JSON.parse(event.data);
// Write to buffer, not state
bufferRef.current[price.symbol] = price;
};
return () => {
wsRef.current?.close();
};
}, []);
return (
<div>
{Object.entries(prices).map(([symbol, price]) => (
<div key={symbol}>
{symbol}: ${price.value}
</div>
))}
</div>
);
}Notice the bufferRef.current assignment inside onmessage. This is not a state update—it's a synchronous mutation of a ref. This is blazingly fast because React doesn't re-render after a ref mutation. We can handle thousands of messages per second in this step with minimal CPU cost.
The key insight: you only care about the latest price for each symbol. Old messages are irrelevant. By keying the buffer by symbol and overwriting, you naturally discard stale data.
Flush with requestAnimationFrame
Now we need to move buffered data into state at a controlled cadence. The browser provides requestAnimationFrame, which schedules a callback to run before the next paint, aligned to the display's refresh rate (usually 60 FPS).
Here's the updated component:
import { useEffect, useRef, useState } from 'react';
interface Price {
symbol: string;
value: number;
timestamp: number;
}
export function PriceTicker() {
const [prices, setPrices] = useState>({});
const bufferRef = useRef>({});
const wsRef = useRef(null);
const rafIdRef = useRef(null);
// Flush buffer to state at rAF cadence
const flush = () => {
if (Object.keys(bufferRef.current).length > 0) {
setPrices((prev) => ({
...prev,
...bufferRef.current,
}));
bufferRef.current = {};
}
rafIdRef.current = requestAnimationFrame(flush);
};
useEffect(() => {
wsRef.current = new WebSocket('wss://price-feed.example.com');
wsRef.current.onmessage = (event) => {
const price: Price = JSON.parse(event.data);
bufferRef.current[price.symbol] = price;
};
// Start the flush loop
rafIdRef.current = requestAnimationFrame(flush);
return () => {
wsRef.current?.close();
if (rafIdRef.current) {
cancelAnimationFrame(rafIdRef.current);
}
};
}, []);
return (
<div>
{Object.entries(prices).map(([symbol, price]) => (
<div key={symbol}>
{symbol}: ${price.value}
</div>
))}
</div>
);
}This pattern is elegant because it aligns state updates with the browser's repaint cycle. Instead of re-rendering on every message, we re-render at most 60 times per second—the maximum the display can show anyway. The overhead is negligible: one state update per frame, whether there were 1 or 1,000 messages that frame.
I've used this in a ride-hailing platform receiving GPS coordinates at 4 Hz per driver, and a travel platform receiving seat availability updates from dozens of suppliers. The consistency is remarkable: frame drops vanish.
Only the latest price matters per symbol
In financial and real-time data scenarios, you almost never need historical message order. You need the current state of the world. This is why the buffer structure—a simple object or Map keyed by symbol—is so effective.
Consider a scenario: you receive messages for BTC in this order:
Message 1: BTC = $45,000 at 10:00:00.001
Message 2: BTC = $45,100 at 10:00:00.002
Message 3: BTC = $45,050 at 10:00:00.003
Message 4: BTC = $45,150 at 10:00:00.004
If you queue these and process them one by one, you render 4 times for one symbol over 3 milliseconds. Wasteful. With a buffer, only Message 4 survives to the next rAF flush. You render once with the latest value. The UI shows $45,150, which is correct.
Here's a more realistic example with multiple symbols:
export function PriceBoard() {
const [prices, setPrices] = useState>({});
const bufferRef = useRef>({});
const wsRef = useRef(null);
const rafIdRef = useRef(null);
const flush = () => {
const buffered = Object.keys(bufferRef.current);
if (buffered.length > 0) {
setPrices((prev) => {
const next = { ...prev };
for (const symbol of buffered) {
next[symbol] = bufferRef.current[symbol];
}
return next;
});
bufferRef.current = {};
}
rafIdRef.current = requestAnimationFrame(flush);
};
useEffect(() => {
wsRef.current = new WebSocket('wss://prices.example.com');
wsRef.current.onmessage = (event) => {
const price: Price = JSON.parse(event.data);
// Buffer overwrites old message for same symbol
bufferRef.current[price.symbol] = price;
};
rafIdRef.current = requestAnimationFrame(flush);
return () => {
wsRef.current?.close();
if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current);
};
}, []);
return (
<table>
<thead>
<tr>
<th>Symbol</th>
<th>Price</th>
<th>Timestamp</th>
</tr>
</thead>
<tbody>
{Object.entries(prices).map(([symbol, price]) => (
<tr key={symbol}>
<td>{symbol}</td>
<td>${price.value}</td>
<td>{new Date(price.timestamp).toLocaleTimeString()}</td>
</tr>
))}
</tbody>
</table>
);
}The assumption here—and it holds true in 90% of real-time cases—is that you want the most recent state, not a queue of historical states. If you do need order guarantees, you'd buffer events as an array and process them sequentially on flush, but that's rare.
Profile before and after
I never ship this pattern without profiling. Chrome DevTools makes it trivial.
Before optimization:
- Open React DevTools Profiler
- Start recording
- Let your WebSocket receive messages for 5 seconds
- Stop recording
- Look at the flame graph
You'll see your component rendering 300+ times per second, each render taking 1–5ms. If you have a complex render tree (tables, charts), that balloons to 10–50ms per render. The browser can't keep up, and you see frame drops (red spikes in the DevTools).
After optimization:
- Same steps
- Your component now renders ~60 times per second
- Each render is much faster because the state delta is small (only changed symbols)
- Frame drops disappear
I typically capture a 5-second profile and compare the "Ranked chart" tab to see which components burned the most time. Before, it's your price component. After, it's usually something unrelated (or nothing).
Here's a quick profiling snippet to log render counts:
export function PriceTickerWithMetrics() {
const [prices, setPrices] = useState>({});
const bufferRef = useRef>({});
const renderCountRef = useRef(0);
const messageCountRef = useRef(0);
const wsRef = useRef(null);
const rafIdRef = useRef(null);
const flush = () => {
if (Object.keys(bufferRef.current).length > 0) {
renderCountRef.current++;
setPrices((prev) => ({
...prev,
...bufferRef.current,
}));
bufferRef.current = {};
}
rafIdRef.current = requestAnimationFrame(flush);
};
useEffect(() => {
wsRef.current = new WebSocket('wss://prices.example.com');
wsRef.current.onmessage = () => {
messageCountRef.current++;
};
rafIdRef.current = requestAnimationFrame(flush);
const logInterval = setInterval(() => {
console.log(
`Messages: ${messageCountRef.current}, Renders: ${renderCountRef.current}`,
);
messageCountRef.current = 0;
renderCountRef.current = 0;
}, 1000);
return () => {
wsRef.current?.close();
if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current);
clearInterval(logInterval);
};
}, []);
return <div>{/* render prices */}</div>;
}Run this for 10 seconds. You'll see output like "Messages: 580, Renders: 60" per second. That's exactly what you want: most messages are absorbed into the buffer and never trigger a render.
Common mistakes
Mistake 1: Forgetting to cancel rAF on unmount. If you don't call cancelAnimationFrame in your cleanup function, the flush loop continues running in the background, leaking memory and CPU. Always store the rAF ID and cancel it.
Mistake 2: Spreading state on every flush. If you use setPrices({ ...prices, ...buffer }) outside of a state setter function, you'll capture stale state. Always use the functional form: setPrices(prev => ({ ...prev, ...buffer })).
Mistake 3: Over-complicating the buffer. A simple object works fine. Don't use a class or a third-party library here—the overhead defeats the purpose. Keep it boring and fast.
Mistake 4: Assuming rAF is always 60 Hz. On some devices (gaming monitors, high-refresh displays) rAF fires 120+ times per second, and on low-end devices it might be 30 Hz. This is fine—the pattern adapts automatically. You'll render at whatever the display supports.
Mistake 5: Not profiling. If you implement this without measuring, you might leave the buffer strategy in place for a low-frequency feed (10 messages/sec) where it adds unnecessary latency. Always measure before and after to justify the complexity.
Key takeaways
- Buffer incoming WebSocket messages in a ref, keyed by identifier (symbol, ID, etc.), to avoid triggering renders for every message.
- Flush the buffer to state using
requestAnimationFrame, which naturally aligns updates to the browser's repaint cycle (usually 60 FPS). - For real-time feeds where only the latest state matters (prices, coordinates, status), this pattern drops re-renders from hundreds per second to ~60, unlocking smooth UIs.
- Always profile with React DevTools before and after to quantify the improvement and spot regressions.
- The pattern is simple: ref for buffer, rAF for flushing, functional state setters for correctness. Don't over-engineer it.
- Clean up: cancel rAF and close WebSockets in your effect cleanup function to avoid leaks.






Comments
Loading comments…