DEV Community

Nainik Mehta
Nainik Mehta

Posted on

React WebSocket Performance: RAF Buffering Pattern

Introduction

If you've ever wired WebSocket.onmessage straight into setState you know the symptoms: hundreds of renders per second, the profiler filled with long commits, and inputs that feel like a slideshow. When message rates climb, naive render-per-message patterns explode the main thread and ruin interactivity.

This article shows a small, high-ROI fix for React WebSocket performance: buffer incoming frames outside React, flush once per animation frame with requestAnimationFrame (RAF), and expose updates through useSyncExternalStore so components get a concurrent-safe snapshot. The change is tiny in surface area but can turn a locked UI into a buttery 60fps experience.

Why setState per message betrays you

React's reconciliation and commit work is not free. Every render has CPU cost (render, diffing, commit, layout, paint). If your WebSocket triggers setState for every message, the app may try to render many times per frame. On a 60Hz display you have ~16.7ms per frame — multiple renders inside that budget quickly cause dropped frames and jank.

React's automatic batching helps in many cases, but independent asynchronous callbacks (WebSocket onmessage, worker messages) can still schedule separate renders. The reliable solution is to collapse those asynchronous messages before React ever sees them.

The pattern: buffer → RAF flush → external store

High level steps:

  • Buffer messages outside React (module scope or a ref) so incoming callbacks never call setState directly.
  • Schedule a single flush per display frame with requestAnimationFrame. That collapses all messages arriving within that frame into one update.
  • Expose a small external store with subscribe/getSnapshot and useSyncExternalStore in components so React reads a stable, concurrent-safe snapshot.
  • Offload heavy parsing/validation to a Web Worker when the message rate or payload cost justifies it.

This aligns updates to display cadence (60Hz, 120Hz, etc.) and bounds the render rate by the refresh rate rather than the network.

Minimal example (concept)

Below is a compact illustrative store that shows the essential bits. This is concept code — production code should add caps, error handling, and lifecycle management.

// messageStore.js (module scope)
let buffer = [];
let snapshot = [];
const subscribers = new Set();
let frameScheduled = false;

function scheduleFlush() {
  if (frameScheduled) return;
  frameScheduled = true;
  requestAnimationFrame(() => {
    frameScheduled = false;
    if (buffer.length === 0) return;
    // create a new immutable snapshot so consumers can compare by identity
    snapshot = buffer.slice(0);
    buffer.length = 0;
    for (const s of subscribers) s();
  });
}

export function pushMessage(msg) {
  buffer.push(msg);
  scheduleFlush();
}

export function getSnapshot() {
  return snapshot;
}

export function subscribe(cb) {
  subscribers.add(cb);
  return () => subscribers.delete(cb);
}

// React hook
import { useSyncExternalStore } from 'react';
export function useMessages() {
  return useSyncExternalStore(subscribe, getSnapshot);
}
Enter fullscreen mode Exit fullscreen mode

Wire your WebSocket to pushMessage instead of calling setState:

const ws = new WebSocket('wss://example/stream');
ws.onmessage = e => pushMessage(JSON.parse(e.data));
Enter fullscreen mode Exit fullscreen mode

Now components calling useMessages() will re-render at most once per animation frame (and only when buffer had messages), not once per network frame.

Offload parsing and validation to a Web Worker

JSON.parse and validation can be expensive on hot streams. Move parsing into a worker and post already-parsed payloads to the main thread. That keeps the main thread’s work limited to frame-aligned flushes and snapshot assignment.

Worker (worker.js):

self.onmessage = (_, e) => {
  // e.data contains raw string frames or an ArrayBuffer
  let parsed = null;
  try { parsed = JSON.parse(e.data); } catch { return; }
  // optionally validate or transform
  self.postMessage(parsed);
};
Enter fullscreen mode Exit fullscreen mode

Main thread:

const w = new Worker('worker.js');
w.onmessage = (e) => pushMessage(e.data);
socket.onmessage = e => w.postMessage(e.data);
Enter fullscreen mode Exit fullscreen mode

This separation means the main thread only handles the inexpensive pushMessage + RAF scheduling work.

Measured impact

In a real-world streaming feature sending ~80 tokens/sec, we measured commit durations drop dramatically after switching to an RAF-batched external store: average commit durations moved from ~52ms to ~5ms. The app went from locked and janky to responsive and smooth. Your mileage will vary, but the pattern collapses unbounded render storms into a bounded, display-aligned cadence.

Practical tips and trade-offs

  • Use module-scope or a stable useRef for the buffer so you don't re-create the subscriber functions on each render.
  • Keep snapshots immutable (or identity-stable when unchanged) so useSyncExternalStore can detect changes efficiently.
  • Cap buffers and implement overflow strategies (drop oldest, drop by priority) to avoid memory growth if the consumer can't keep up.
  • Remember that useSyncExternalStore updates are synchronous relative to React transitions — that's desirable for consistency.
  • For single-value ultra-high-frequency updates (e.g., 500Hz price ticks), consider drawing directly into a canvas or updating a DOM node via refs to entirely avoid reconciliation.

6-point pre-shipping checklist

1) Buffer messages outside React (useRef or a module-level structure).
2) Flush once per frame via requestAnimationFrame.
3) Use useSyncExternalStore for a concurrent-safe subscription surface.
4) Keep snapshot updates minimal and give them stable identity (immutable when practical).
5) Offload heavy parsing/validation to a Web Worker when message/parse cost is high (> ~1000 msgs/sec or heavy JSON).
6) Measure commit durations and profiler traces before and after the change.

When to not bother

If message rates are low (under ~30 updates/sec per stream) and payloads are tiny, the added complexity might not be worth it. Use the profiler and Performance panel to make a data-driven decision.

Conclusion

If your realtime UI ever feels sluggish under heavy streams, the RAF-batched external store pattern is the highest-ROI fix you can apply. It’s a small, maintainable layer that protects React from unbounded network-driven renders and leverages useSyncExternalStore to give components a safe, consistent view of the stream.

Curious: what was the worst render storm you chased down, and how did you fix it? Share your stories — this pattern has saved many dashboards and chat UIs from becoming unusable under load.

Top comments (0)