There is a category of performance bug that only shows up in production, under real load, with real data rates. You built the feature locally against a mocked WebSocket that fires maybe ten messages a second. Everything felt snappy. Then you connected it to the actual stream, and suddenly your UI locks up, inputs lag, and scroll feels like it's moving through mud.
The cause is almost always the same: raw WebSocket events wired directly to React state.
What "Render Every Message" Actually Costs You
When you do this:
socket.onmessage = (event) => {
setData(JSON.parse(event.data));
};
you are handing React a new state update for every single message that arrives. At moderate frequencies this is fine. At 100Hz it starts to feel wrong. At 1000Hz, which is not unusual for market data, sensor feeds, or multiplayer game state, you are scheduling 1000 reconciliation cycles per second on the main thread.
React batches state updates in some cases, but it does not collapse an unbounded stream of external events for you. Each setState call queues work. That work competes with input handling, with CSS animations, with scroll jank detection. The browser has one main thread, and you are burying it.
The symptom people usually notice first is not a frozen UI but a "sticky" one. Typing lags by a frame or two. Dropdowns feel reluctant. The stream looks fine in the network tab, but the experience has quietly degraded.
The Architecture Problem Underneath the Symptom
The root issue is treating React as a data layer when it is a rendering layer. State in React is not a place to store raw stream data. It is a description of what the UI should look like right now, and "right now" is measured in frames, not in microseconds.
A WebSocket stream and a render cycle are two separate concerns operating at two different cadences. When you collapse them into one, you are letting the stream's frequency dictate your render frequency. That is backwards.
The stream should push into a buffer that you own outside React. The render cycle should pull from that buffer at a controlled rate, on a display tick, and reconcile once per frame rather than once per message.
A Buffering Pattern That Actually Works
The approach that holds up well in production looks roughly like this:
const messageBuffer = useRef([]);
const [snapshot, setSnapshot] = useState([]);
useEffect(() => {
const socket = new WebSocket(url);
socket.onmessage = (event) => {
// Write to the buffer — no React involvement here
messageBuffer.current.push(JSON.parse(event.data));
};
let rafId;
const flush = () => {
if (messageBuffer.current.length > 0) {
const batch = messageBuffer.current.splice(0);
setSnapshot((prev) => [...prev, ...batch]);
}
rafId = requestAnimationFrame(flush);
};
rafId = requestAnimationFrame(flush);
return () => {
socket.close();
cancelAnimationFrame(rafId);
};
}, [url]);
The key move here is that socket.onmessage never calls setState. It writes into a ref, which is just a plain JavaScript object. No render is triggered. No reconciliation happens. The stream can run at whatever frequency the backend is pushing without involving React at all.
The requestAnimationFrame loop runs at the browser's display rate, typically 60Hz, and flushes whatever has accumulated since the last frame into a single state update. React sees one update per frame regardless of how many messages arrived.
For more complex cases, useSyncExternalStore is a cleaner fit than this manual pattern, because it gives you a proper external store contract with React's concurrent renderer. But the mental model is the same: the store lives outside React, the stream writes to the store, and React subscribes to snapshots on its own terms.
Matching Frontend Discipline With Backend Discipline
One thing worth noting: the buffering problem on the frontend often reflects a delivery problem on the backend. If your server is emitting updates with no regard for downstream rendering capacity, the frontend has to compensate entirely on its own. The most robust real-time systems treat controlled delivery as a shared responsibility across the stack. Turboline is built around this same idea, handling the transport layer so high-frequency data arrives in a way that does not require heroic frontend workarounds to stay performant.
The Concrete Takeaway
If your real-time feature feels sluggish under load, check whether you are wiring socket events directly to state. Odds are you are, and odds are that is the whole problem. The fix is not a React optimization trick. It is an architectural separation: the stream goes into a buffer you control, and React reads from that buffer at display cadence, on your schedule, not the stream's.
One batch per frame is almost always enough. And it costs the stream nothing to get there.
Top comments (0)