Real-Time Rendering Performance #

A real-time interface that works beautifully with ten updates a second can fall apart at two hundred. The symptoms are familiar: frame rate collapses during bursts, typing into a search box lags behind keystrokes, scrolling stutters, the fan spins up, and the tab’s memory climbs through the afternoon. The WebSocket is rarely at fault — messages arrive fine. The problem is what the page does with each one: parsing it on the main thread, re-rendering a whole tree for a single field, inserting DOM nodes that nobody will ever scroll to, and doing all of it once per message rather than once per frame the screen can actually show.

This area covers the client-side performance of live data: how to decouple message rate from rendering rate, move non-rendering work off the main thread, keep DOM size bounded, and stop tabs multiplying connections. It complements the state patterns in Frontend Real-Time State Hooks & UI Patterns and the server-side rate control in WebSocket Backpressure & Flow Control.

Where a message's cost goes on the client The client-side cost of a WebSocket message split into receiving, decoding, aggregating, applying to state and rendering; decoding and aggregation can move to a worker, applying should be batched per frame, and rendering stays on the main thread and grows with DOM size. Where a message's cost goes on the client Receive network → message event; cheap negligible Decode JSON.parse or binary decode; grows with payload size worker-able Aggregate fold into state: latest-per-key, candles, counts worker-able Apply one store update; batch per frame per frame Render components, style, layout, paint; grows with DOM size main thread Only the bottom layer must run on the main thread — everything above it can be moved or batched
Performance work is mostly about moving cost out of the bottom layer's way.

Prerequisites #

Rendering optimizations assume a sound data path. Before tuning:

  • The server should not send more than the client can use. Coalescing superseded values on the server, as in coalescing high-frequency WebSocket updates, removes work before it crosses the network.
  • State should live in a store with fine-grained subscriptions — useSyncExternalStore selectors, Pinia, Svelte stores or runes — so an update to one entity re-renders only the components that read it. The React version is in React WebSocket state with useSyncExternalStore.
  • Messages should be ordered and deduplicated before they reach rendering, following WebSocket State Sync and Optimistic Updates, so performance work does not paper over correctness bugs.
  • You need a way to measure: the Performance panel, the React/Vue/Svelte devtools profilers, and field metrics such as Interaction to Next Paint.

Core implementation #

The most effective single change for a busy real-time UI is to stop rendering per message. Messages go into a queue; once per animation frame, the queue is folded into one state update. Everything the user sees is identical — the display could never show more than one state per frame — and rendering work is capped at the refresh rate.

// A minimal per-frame pipeline: enqueue on message, apply once per frame.
type Msg = { kind: 'state'; key: string; value: unknown } | { kind: 'event'; data: unknown };

export function createFramePipeline(apply: (latest: Map<string, unknown>, events: unknown[]) => void) {
let latest = new Map<string, unknown>();
let events: unknown[] = [];
let scheduled = false;

const flush = () => {
scheduled = false;
if (latest.size === 0 && events.length === 0) return;
const l = latest, e = events;
latest = new Map(); events = [];
apply(l, e); // exactly one state transition
};

const schedule = () => {
if (scheduled) return;
scheduled = true;
// rAF pauses in hidden tabs; a slow timer keeps state moving there.
if (document.visibilityState === 'visible') requestAnimationFrame(flush);
else setTimeout(flush, 1_000);
};

return {
push(m: Msg) {
if (m.kind === 'state') latest.set(m.key, m.value); // superseded values collapse
else events.push(m.data); // every event kept, in order
schedule();
},
};
}

// Usage: the socket only enqueues.
// const pipe = createFramePipeline((latest, events) => store.applyBatch(latest, events));
// ws.onmessage = (e) => pipe.push(classify(JSON.parse(e.data)));

Two further moves build on this. When decoding or aggregation shows up as long tasks, move the socket into a Web Worker and post one ready-to-render batch per frame, as in parsing WebSocket messages in a Web Worker. When the rendered collection grows with the session, virtualize it so DOM size depends on the viewport, as in virtualizing live-updating lists.

A 300 msg/s dashboard, one change at a time Starting at fourteen frames per second and 380 ms interaction latency, per-frame batching raises frame rate to 48, worker decoding to 57 and virtualization to 60, while interaction latency falls from 380 to 60 milliseconds. A 300 msg/s dashboard, one change at a time illustrative measurements on a mid-range laptop frames per second INP during burst (ms) 0 100 200 300 400 Baseline 48 160 + per-frame batching 57 90 + worker decode 60 60 + virtualized list
Each technique removes a different bottleneck; together they make message rate irrelevant to smoothness.

Diagnosing a slow real-time UI #

Guessing at performance problems wastes time, because the four common bottlenecks look the same to a user — “it’s janky” — and need different fixes. A ten-second profile during a burst distinguishes them.

Rendering per message. The Performance panel shows a regular pattern of short tasks, each containing a framework render, style recalculation and layout, at the message rate rather than the frame rate. The framework profiler shows many commits per second. Fix: per-frame batching.

Main-thread decoding. Long tasks whose bottom-up breakdown is dominated by JSON.parse, a binary decoder, or your aggregation functions. Rendering is a minority of each task. Fix: move the socket and decode into a worker.

DOM size. Rendering tasks that get longer over time even at a constant message rate, with layout and style dominating. The Elements panel shows thousands of rows in a list. Fix: virtualize, and bound history.

Over-broad subscriptions. The framework profiler shows components re-rendering that display nothing that changed — sidebars, headers, unrelated panels. Fix: selector-based subscriptions and stable context values, as in WebSocket context provider in React.

Memory growth is a separate axis. A heap that rises steadily with a constant visible UI is a leak rather than a performance problem; the tools for that are in Memory Leak Prevention.

Symptom to fix A mapping from symptoms to profile signatures and fixes: high message-rate jank to per-frame batching, burst input lag to worker decoding, slowdown over time to virtualization, unrelated re-renders to selectors, and steady heap growth to a leak investigation. Symptom to fix Profile signature Fix Janky at high msg rate render per message rAF batching Input lag in bursts long parse tasks worker decode Slower as session ages layout grows with DOM virtualize + bound Unrelated panels flash broad re-renders selectors Heap climbs steadily retained nodes/listeners leak hunt Profile first; each fix is useless against the other bottlenecks
Five symptoms, five different root causes.

Many tabs, one connection #

Performance is also a server-side concern when users open many tabs. Each tab with its own socket multiplies connections, fan-out and reconnect storms by the tab count, and multiplies client-side decoding work on the user’s machine too. A SharedWorker that owns one socket and serves every tab removes that multiplier and doubles as a natural home for decoding, as shown in sharing one WebSocket across browser tabs. For products where power users keep many tabs open — trading, operations consoles, support tools — this is often the single largest reduction in server connection count available.

Sharing also changes where the per-message work lands. With one socket per tab, eight open tabs decode every message eight times on the same machine, competing for the same CPU cores. With a shared worker, the message is decoded once and each tab receives a compact, ready-to-render batch — so the multi-tab user, who is usually the heaviest user, gets the biggest improvement in responsiveness as well as the server getting the biggest reduction in load.

Choosing a rendering surface for dense live views #

The techniques above assume a DOM-based UI, which is the right default for most real-time screens: lists, tables, feeds and dashboards with a moderate number of changing values. Some views change so much, so often, that even a perfectly batched DOM update per frame is too expensive — a depth-of-market ladder repainting hundreds of price levels, a chart streaming thousands of points, a map with moving markers. For those, the rendering surface itself is the lever.

DOM is accessible, styleable and works with every framework, and its cost scales with the number of elements that change per frame. It is the right choice up to a few hundred changing cells per frame.

Canvas 2D draws pixels directly, so its cost scales with the area redrawn rather than with element count. It suits charts and dense grids where thousands of values update together. You give up built-in accessibility and text selection, so pair it with an accessible summary or a DOM fallback table for assistive technology.

WebGL / WebGPU move drawing to the GPU and handle hundreds of thousands of points, at the cost of considerably more code. Charting libraries that use them are the practical way to get that performance without writing shaders.

Whatever the surface, the upstream rules stay the same: coalesce on the server, decode off the main thread when it matters, and apply once per frame. A Canvas chart fed per message still wastes work on frames nobody sees.

Measuring in the field #

Lab profiles tell you whether a fix works on your machine; field data tells you whether it works for users on slower hardware during real traffic peaks. Three measurements are worth collecting from production pages that display live data. Interaction to Next Paint captures how long user input waits, which is exactly what main-thread message handling degrades. Long animation frames, available through the Long Animation Frames API in Chromium, attribute slow frames to the scripts that caused them, so a regression in a WebSocket handler shows up by name. And a simple client-side counter of messages received versus frames rendered, sampled and reported with other telemetry, shows whether batching is working as intended on real devices. Segment all three by page and by message rate: problems in real-time UIs appear during bursts, and averages over a whole session hide them.

Configuration reference #

Parameter Typical value Why
Flush cadence (visible) every animation frame Caps rendering at the refresh rate
Flush cadence (hidden tab) 1 s timer rAF pauses; keep state current cheaply
Queue safety valve 10,000 events Force a flush in pathological bursts
Worker batch protocol pull (ready → batch) Main thread sets the pace; worker coalesces
List overscan 5–10 rows Smooth scrolling without rendering off-screen history
List history cap 1,000–5,000 items Bounded memory and scroll range
Row identity stable id keys Inserts do not re-render every row
Awareness / cursor updates ≤ 12–15 per second Quadratic fan-out in shared rooms
Tabs per socket all tabs of the origin One connection per browser via SharedWorker

Edge cases & gotchas #

Batching user acknowledgements. Deferring the confirmation of the user’s own action by a frame is invisible, but deferring it behind a large batch of other updates is not. Apply direct replies to user actions immediately and batch everything else.

Hidden-tab backlogs. requestAnimationFrame does not run in hidden tabs. A batcher that relies on it alone accumulates everything received while hidden and applies it in one huge frame on return. Fall back to a timer while hidden.

Transferring versus cloning. Posting large objects from a worker is not free; structured cloning costs roughly as much as parsing. Send compact, render-ready batches and transfer binary buffers rather than cloning them.

Virtualization and accessibility. Rows that are not rendered cannot be read by assistive technology. Announce new content through a live region, and keep keyboard focus on rendered rows.

Measuring in development mode. Framework development builds, StrictMode double rendering and devtools extensions distort performance. Measure production builds, ideally with CPU throttling to approximate mid-range devices.

Verification #

Establish a repeatable burst test before optimizing: a fake socket (or a staging feed) that plays a fixed recording at a fixed rate, so before-and-after numbers are comparable. Then measure four things.

// Frame rate and long tasks during a burst, from the page itself.
let frames = 0;
const tick = () => { frames++; requestAnimationFrame(tick); };
requestAnimationFrame(tick);
const longTasks: number[] = [];
new PerformanceObserver((list) => list.getEntries().forEach((e) => longTasks.push(e.duration)))
.observe({ type: 'longtask', buffered: true });
setTimeout(() => {
console.info({ fps: frames / 10, longTasks: longTasks.length, worstMs: Math.max(0, ...longTasks) });
}, 10_000);

Frames per second should stay near the refresh rate; long tasks during the burst should be rare and short; the framework profiler should show at most one commit per frame; and DOM node count (document.getElementsByTagName('*').length) should stay flat as the session continues. In the field, track INP and long-animation-frame data from real users, segmented by pages with live data.

Guides in this area #

FAQ #

How many WebSocket messages per second can a browser handle? #

Receiving thousands per second is fine; rendering each one is not. With per-frame batching, off-main-thread decoding and virtualized lists, the message rate stops mattering to smoothness, and the practical limit becomes bandwidth and decode CPU.

Is requestAnimationFrame batching better than debouncing? #

Yes, for displayed data. Debouncing delays updates until messages stop, which during a continuous stream may be never. Per-frame batching applies everything at a steady cadence matched to the display.

Do I need a Web Worker for every real-time app? #

No. Add one when profiles show decoding or aggregation as long tasks. Many apps with small messages are served perfectly well by per-frame batching alone.

Why is my app smooth on my laptop but janky for users? #

Developer machines are typically several times faster than the median user’s device, and development testing rarely reproduces peak message rates. Profile with CPU throttling at 4–6×, replay recorded peak traffic, and collect field INP and long-frame data segmented by message rate to see what users actually experience.

Should batching happen in the framework or before it? #

Before it. Frameworks batch updates made within one task, but WebSocket messages arrive as separate tasks, so the framework cannot combine them. Collect messages yourself and hand the framework one update per frame.

Does Canvas or WebGL rendering avoid these problems? #

It avoids DOM size and layout costs, which is why high-frequency charts and trading ladders often use Canvas. You still need per-frame batching and off-thread decoding; Canvas only changes the cost of the final render step.

Back to Frontend Real-Time State Hooks & UI Patterns.