The Performance Wall: Why React Isn't a Data Buffer
If you’ve ever built a real-time application—a trading dashboard, a crypto ticker, or a live sensor monitor—you’ve likely hit the "React Performance Wall." You pipe your WebSocket messages directly into useState, and suddenly, your browser becomes a stuttering, unresponsive mess.
The culprit is simple but often misunderstood: React is a UI library, not a data buffer.
When you treat React state as the ultimate source of truth for every single byte of incoming data, you are essentially asking React to trigger a reconciliation cycle for every packet. If your backend is pushing data at 1,000Hz, you are trying to force 1,000 renders per second. Even the most optimized React app cannot handle that. You are blocking the main thread, tanking your frame rate, and leaving your users with a "lag machine."
The "Death by a Thousand Cuts" Problem
React’s reconciliation process is brilliant, but it is not built to trigger 1,000 times a second. Every setState call schedules a render. If you have a complex component tree, each render triggers diffing, lifecycle hooks, and DOM updates.
When updates arrive faster than the browser can paint (typically 60Hz or 16.67ms per frame), you create a backlog of "long tasks." The browser’s main thread becomes so busy trying to keep up with the data stream that it ignores user interactions like clicks or scrolls. Your UI stops being a tool and starts being a bottleneck.
The Architectural Shift: Decouple Ingestion from Rendering
The fix isn't to optimize your components; it's to change your architecture. You need to stop letting React "know" about every single data point.
At York.ie, we achieved a 40% boost in responsiveness by implementing a Dam Pattern. Instead of pushing packets directly into state, we treat the data flow like a dam: the water (data) flows in at high pressure, but we release it to the UI in controlled, manageable bursts.
The Implementation Strategy
- Buffer Ingested Data: Use a mutable
useRefor an external store (like Zustand or a simple object) to hold incoming data. React doesn't need to track this. - Synchronize with the Browser: Use
requestAnimationFrame(RAF) to create a synchronization loop. This ensures you only flush updates to the UI at the display's refresh rate (usually 60Hz). - Aggregate and Flush: Every 16ms, take whatever is in the buffer, merge it, and trigger one state update.
Code Example: The Throttled Buffer Hook
Here is a simple, production-ready pattern for handling high-frequency updates:
import { useState, useRef, useEffect } from 'react';
export function useHighFrequencyData(socketUrl) {
const [data, setData] = useState({});
const bufferRef = useRef({});
const rafRef = useRef(null);
useEffect(() => {
const ws = new WebSocket(socketUrl);
ws.onmessage = (event) => {
const payload = JSON.parse(event.data);
// 1. Update the mutable buffer (no re-render!)
bufferRef.current = { ...bufferRef.current, ...payload };
// 2. Schedule a flush if one isn't already pending
if (!rafRef.current) {
rafRef.current = requestAnimationFrame(() => {
setData(bufferRef.current);
rafRef.current = null;
});
}
};
return () => {
ws.close();
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [socketUrl]);
return data;
}
Beyond the Basics: Scaling Further
Once you have decoupled your data from your render cycle, you can take it further:
- Virtualize Your Lists: If you are displaying a high-frequency feed of hundreds of items, use virtual scrolling (e.g.,
@tanstack/react-virtual). Don't render DOM nodes that aren't on screen. - Offload Parsing: If your WebSocket messages require heavy JSON parsing or data transformation, move that logic into a Web Worker. Let the worker handle the heavy lifting and send only the final, processed data to the main thread.
- Memoize Surgically: Use
React.memowith custom comparison functions on components that receive data frequently. This ensures that even when a flush occurs, only the components that actually changed re-render.
Conclusion
Your users don't need to see 1,000 updates a second. They need a smooth, responsive interface that doesn't freeze their browser. By moving your data logic out of the component lifecycle and into a dedicated, throttled data layer, you stop fighting React and start working with it.
Stop building lag machines. Start building high-performance real-time applications. How are you handling your real-time streams? Let’s discuss in the comments.
Top comments (1)
This is basically the exact problem useSyncExternalStore exists for, treating the ref/buffer as an external store and letting React handle the tearing-safe subscription instead of hand rolling the RAF flush yourself. Curious if you tried that route before landing on manual RAF scheduling, or if there was a specific reason to avoid it. Also the spread on every message, {...bufferRef.current, ...payload}, means the buffer object keeps getting copied on every single packet even between flushes, if payload keys are mostly unique ids that spread cost grows with buffer size and could itself become the bottleneck under real 1000Hz load. Mutating the buffer object directly instead of spreading would skip that entirely since nothing reads it until the RAF flush anyway