Throttling Dashboard Updates to the Frame Rate Permalink to this section
Part of Live Dashboards & Metrics Feeds, under Real-Time Application Patterns.
A dashboard that renders on every Server-Sent Event works perfectly at five messages per second and falls over at two hundred. The fix is not to slow the stream down; it is to stop tying rendering to message arrival.
Symptom & Developer Intent Permalink to this section
The pattern is consistent across frameworks:
- The dashboard is smooth in normal operation but the tab becomes sluggish or unresponsive during an incident, precisely when the metrics are busiest.
- Chrome shows the “Page Unresponsive” dialog, or scrolling stutters while the stream is active.
- The Performance panel shows long tasks back-to-back, each one a React commit or a chart redraw triggered by a single
messageevent. - CPU usage in the tab tracks the event rate linearly, and closing the
EventSourcerestores responsiveness instantly. - On a phone, the battery drains noticeably while the dashboard is open.
The intent is a dashboard that stays responsive at any event rate by doing a constant amount of rendering work per display frame, while still showing the latest value of everything.
Root Cause Analysis Permalink to this section
The browser delivers each SSE event as a separate task on the main thread. If the handler for that task updates component state, the framework schedules a render, and with most state libraries that render happens before the next event is processed. At 200 events per second that is 200 renders per second — but a 60 Hz display can only show 60 frames. More than two thirds of the rendering work produces pixels nobody will ever see, and it competes with input handling for the same thread.
Two further effects make it worse. First, most dashboard updates are overwrites: the CPU value at t=5 ms is irrelevant once the value at t=10 ms arrives. Rendering both is pure waste. Second, rendering takes longer as the dashboard grows, so the render rate the thread can sustain falls while the event rate stays the same. The moment render time exceeds the inter-event gap, tasks queue faster than they drain and the tab effectively locks up.
requestAnimationFrame solves both. It runs a callback once before each paint, at the display’s refresh rate, and not at all in a hidden tab. If events only mutate a buffer and the animation-frame callback does the rendering, render work becomes bounded by the display rather than by the stream.
Step-by-Step Resolution Permalink to this section
Step 1 — Separate “receive” from “render” Permalink to this section
Event handlers must do the minimum: parse and merge into a plain, non-reactive object. Nothing that triggers a framework render may run here.
// stream-buffer.js — receives at any rate, never renders.
export function createStreamBuffer() {
let pending = {}; // latest value per key since the last flush
let dirty = false;
return {
merge(delta) { Object.assign(pending, delta); dirty = true; },
take() {
if (!dirty) return null;
const out = pending;
pending = {};
dirty = false;
return out;
},
};
}
Because the buffer keeps only the latest value per key, twenty deltas that touch cpu collapse into one entry. That is coalescing on the client, and it is only correct for overwrite semantics. Append-shaped data — log lines, notifications — needs an array instead, covered in step 4.
Step 2 — Flush once per animation frame Permalink to this section
// frame-loop.js — one render per display frame, and none while the tab is hidden.
export function startFrameLoop(buffer, apply) {
let raf = 0;
const tick = () => {
const changes = buffer.take();
if (changes) apply(changes); // exactly one state update per frame
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}
Step 3 — Wire it into the component Permalink to this section
In React the only state update happens inside the frame loop, so there is at most one commit per frame regardless of the event rate.
import { useEffect, useState } from 'react';
import { createStreamBuffer } from './stream-buffer';
import { startFrameLoop } from './frame-loop';
export function useFrameThrottledStream(url) {
const [values, setValues] = useState({});
useEffect(() => {
const buffer = createStreamBuffer();
const es = new EventSource(url);
es.addEventListener('snapshot', (e) => { buffer.merge(JSON.parse(e.data)); });
es.addEventListener('delta', (e) => { buffer.merge(JSON.parse(e.data)); });
const stop = startFrameLoop(buffer, (changes) =>
setValues((prev) => ({ ...prev, ...changes })),
);
return () => { stop(); es.close(); };
}, [url]);
return values;
}
The effect’s cleanup closes the stream and cancels the loop together, which is the leak-free shape described in preventing EventSource memory leaks in React. A Vue composable is identical in structure: mutate a plain object in the listener and assign to a shallowRef inside the frame callback.
Step 4 — Handle append-shaped events separately Permalink to this section
A feed of alerts on the same dashboard must not lose items. Buffer them in an array and cap it, so a burst cannot build an unbounded render.
const MAX_ALERTS_PER_FRAME = 50;
let alertQueue = [];
es.addEventListener('alert', (e) => { alertQueue.push(JSON.parse(e.data)); });
function flushAlerts(render) {
if (!alertQueue.length) return;
const batch = alertQueue.splice(0, MAX_ALERTS_PER_FRAME); // spread big bursts over frames
render(batch);
}
Draining at most fifty per frame spreads a burst of two thousand alerts over forty frames — under a second — instead of one frame that takes a second to render.
Step 5 — Move heavy parsing off the main thread when needed Permalink to this section
If JSON.parse itself shows up in profiles (large snapshots, hundreds of events per second), consume the stream in a Web Worker with a fetch-based reader and post coalesced changes to the page once per frame. The fetch-based SSE clients topic shows the reader; the worker simply runs it and posts buffer.take() results.
Validation & Monitoring Permalink to this section
Validate in the browser, under load, with the tools that show main-thread time.
- Generate a busy stream locally. A tiny script that emits deltas every 5 ms is enough:
# Emit 200 deltas per second for 30 seconds to stress the client.
node -e '
require("http").createServer((q, r) => {
r.writeHead(200, {"Content-Type":"text/event-stream","Cache-Control":"no-cache"});
let n = 0;
const t = setInterval(() => {
r.write(`event: delta\nid: ${++n}\ndata: {"cpu":${(Math.random()*100).toFixed(1)}}\n\n`);
}, 5);
setTimeout(() => { clearInterval(t); r.end(); }, 30000);
}).listen(8787)'
- Record 10 seconds in the Performance panel before and after the change. Before: a solid wall of long tasks. After: short event tasks and one render task per frame.
- Check the Frames track: dropped frames should disappear, and interaction to next paint on a click while the stream runs should fall well under 200 ms.
- Add a cheap runtime counter so regressions are visible without profiling:
let renders = 0, events = 0;
setInterval(() => {
performance.mark('stream-stats', { detail: { events, renders } });
if (renders > 70) console.warn('more renders than frames', { events, renders });
renders = events = 0;
}, 1000);
In production, report the event rate and render rate as custom metrics from real users. A render rate above the refresh rate means a code path is still rendering outside the frame loop.
Production Checklist Permalink to this section
Frequently Asked Questions Permalink to this section
Is throttling to the frame rate the same as debouncing?
No. A debounce delays rendering until events stop, which on a continuous stream means the view never updates. Frame alignment renders on every frame that has changes, so latency is at most one frame and the view keeps moving.
Should the server slow the stream down instead?
The server should coalesce too, because bandwidth and fan-out cost money. But the client still needs frame alignment, since bursts happen regardless of server cadence and the client is the only party that knows how fast it can paint.
What happens to buffered updates in a background tab?
Animation frames stop, so nothing is rendered, while the buffer keeps only the latest value per key. When the tab becomes visible the first frame applies everything at once. Append-shaped queues should be capped so a long-hidden tab does not return to a backlog of thousands.
Does React 18 automatic batching make this unnecessary?
Automatic batching merges updates made within the same task. Each SSE event is its own task, so separate events still produce separate renders. Frame alignment is what merges across events.