Batching WebSocket updates with requestAnimationFrame #

A live dashboard receives 200 WebSocket messages a second during market open. Each message updates the store, each store update schedules a render, and the browser spends its whole frame budget re-rendering tables that change several times between two paints. Frame rate drops to 12 fps, input lags, and the Performance panel shows a solid wall of “Recalculate style” and “Layout”. None of that work was visible to the user: a screen that refreshes 60 times a second can only ever show one state per frame. Batching incoming messages and applying them once per animation frame turns 200 renders a second into at most 60, with no loss of information on screen.

Root cause #

WebSocket message events are delivered as separate tasks, as fast as the network hands them over. If each handler mutates UI state immediately, the framework (or your own DOM code) does rendering work per message. Some frameworks batch synchronous updates within one task, but separate WebSocket messages are separate tasks, so each one gets its own render. Worse, DOM reads interleaved with writes in those renders — measuring a row height, reading a scroll position — force synchronous layout, multiplying the cost.

The display, meanwhile, paints at the refresh rate. Any state applied between two paints and then overwritten before the next paint was rendered for nobody. The waste is the ratio of message rate to frame rate, and at high message rates it dominates the main thread.

Messages vs frames in one 50 ms window Within fifty milliseconds six messages arrive but only three frames paint, so rendering per message does twice the work that can ever be seen. Messages vs frames in one 50 ms window msg (2 ms) msg (7 ms) msg (12 ms) frame 1 paints (16.7 ms) msg (21 ms) msg (27 ms) frame 2 paints (33.3 ms) msg (41 ms) frame 3 paints (50 ms) At 200 messages per second, more than two thirds of per-message renders are never displayed
Only the state at each paint is ever seen.

Resolution #

Queue messages as they arrive and flush the queue in a requestAnimationFrame callback, applying everything received since the last frame as one state update. Where messages are superseding updates to the same entity, coalesce by key in the queue so each entity is written once per frame. Because requestAnimationFrame does not fire in background tabs, fall back to a timer there, so state still advances and the queue cannot grow without bound.

const BACKGROUND_FLUSH_MS = 1_000;     // hidden tabs: rAF is paused; flush slowly instead
const MAX_QUEUE = 10_000; // safety valve for pathological bursts

type Update = { key: string; value: unknown };

export class FrameBatcher {
private pending = new Map<string, unknown>(); // keyed: last value per key wins
private events: unknown[] = []; // unkeyed: every item kept, in order
private scheduled = false;

constructor(private apply: (batch: { updates: Map<string, unknown>; events: unknown[] }) => void) {
document.addEventListener('visibilitychange', () => this.schedule());
}

pushUpdate(u: Update) {
this.pending.set(u.key, u.value);
this.schedule();
}

pushEvent(e: unknown) {
this.events.push(e);
if (this.events.length > MAX_QUEUE) this.flush(); // never let a burst grow unbounded
else this.schedule();
}

private schedule() {
if (this.scheduled) return;
this.scheduled = true;
if (document.visibilityState === 'visible') requestAnimationFrame(() => this.flush());
else setTimeout(() => this.flush(), BACKGROUND_FLUSH_MS);
}

private flush() {
this.scheduled = false;
if (this.pending.size === 0 && this.events.length === 0) return;
const batch = { updates: this.pending, events: this.events };
this.pending = new Map();
this.events = [];
this.apply(batch); // ONE state transition per frame
}
}

// Wiring: the socket only enqueues; the store updates once per frame.
const batcher = new FrameBatcher(({ updates, events }) => {
store.setState((s) => {
const prices = { ...s.prices };
for (const [symbol, price] of updates) prices[symbol] = price as number;
return { prices, trades: [...(events as Trade[]), ...s.trades].slice(0, 500) };
});
});

socket.addEventListener('message', (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'price') batcher.pushUpdate({ key: msg.symbol, value: msg.price });
else if (msg.type === 'trade') batcher.pushEvent(msg);
});

type Trade = { id: string; symbol: string; qty: number };
declare const store: { setState: (fn: (s: any) => any) => void };
declare const socket: WebSocket;

Two separate queues reflect the two kinds of message. State updates (a price, a cursor, a counter) supersede each other, so the batcher keeps only the latest per key. Events (a trade, a chat line, a log entry) each matter, so they are kept in order and appended in one operation. The same distinction appears on the server in coalescing high-frequency WebSocket updates; coalescing on both sides is complementary — the server saves bandwidth, the client saves rendering.

In React, the flush becomes one store update, and with a store read through useSyncExternalStore selectors only components whose slice changed re-render, as described in React WebSocket state with useSyncExternalStore. In Vue and Svelte, assign the merged object once per flush rather than mutating reactive state per message.

Renders per second on a 60 Hz display Rendering per message scales with message rate up to a thousand renders per second, while rendering per frame is capped at sixty regardless of message rate. Renders per second on a 60 Hz display same message stream, two application strategies render per message render per frame 0 250 500 750 1.0k 50 msg/s 200 200 msg/s 1,000 msg/s
Per-frame batching caps rendering work at the refresh rate, however fast messages arrive.

Edge cases #

Latency. Batching adds at most one frame (about 16 ms at 60 Hz) between a message arriving and appearing, and on average half that. That is below what anyone perceives in a live display. For interactions where the user is waiting on their own action — the acknowledgement of a click — apply the message immediately instead of batching.

Background tabs. requestAnimationFrame callbacks do not run while a tab is hidden. Without the timer fallback, the queue grows for as long as the tab is in the background and then applies a huge batch on return. The fallback keeps state current at low cost; the visibility listener switches back to rAF when the tab becomes visible.

Ordering across queues. Updates and events are applied in the same flush, but their relative order within the frame is lost. If an event must be interpreted against the state that existed when it arrived, attach that state to the event or keep one ordered queue for that stream.

Verification #

Record a Performance profile for five seconds during a burst. Before batching, the main thread shows a render (scripting, style, layout) for every message; afterwards, one per frame, and the frame-rate meter stays near 60. In React, the Profiler’s commit count per second should drop to at most the refresh rate.

Add a counter to verify the batcher’s effect in production-like conditions:

let messages = 0, flushes = 0;
socket.addEventListener('message', () => messages++);
const origApply = batcher['apply'];
batcher['apply'] = (b) => { flushes++; origApply(b); };
setInterval(() => { console.info(`msgs/s=${messages} flushes/s=${flushes}`); messages = flushes = 0; }, 1_000);

Flushes per second should never exceed the refresh rate, and should drop to one per second while the tab is hidden.

A burst applied in one frame Three messages arrive between frames; the batcher keeps the latest AAPL price and the trade, and when the animation frame fires it applies them in one store update, producing one render and one paint. A burst applied in one frame WebSocket Batcher Store Screen price AAPL 191.02 trade #881 price AAPL 191.04 (replaces) rAF fires one setState: 1 price, 1 trade one render, one paint Three messages, one render — and the screen shows exactly what it would have shown anyway
Collect between frames, apply at the frame.

Operational checklist #

FAQ #

Does React 18 automatic batching make this unnecessary? #

No. Automatic batching combines updates made within one task or microtask. Each WebSocket message is a separate task, so each still triggers its own render. Per-frame batching combines updates across tasks.

Won’t batching make my real-time app feel laggy? #

It adds at most one frame of delay, about 16 ms, which is imperceptible for displayed data. Apply user-initiated acknowledgements immediately if you want instant feedback on actions.

Should I batch on the server instead? #

Do both when you can. Server-side coalescing reduces bandwidth and helps slow clients; client-side batching protects rendering regardless of what the server sends.

What about using a Web Worker? #

A worker moves parsing and aggregation off the main thread, which helps when those dominate. Rendering still happens on the main thread, so combine the two: parse and coalesce in the worker, post one batch per frame. See parsing WebSocket messages in a Web Worker.

Back to Real-Time Rendering Performance.