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 message event.
  • CPU usage in the tab tracks the event rate linearly, and closing the EventSource restores 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.

Renders per event versus renders per frame Timeline of 100 milliseconds showing SSE events arriving every 5 ms, per-event renders filling the main thread, and frame-aligned renders happening once every 16.7 ms. Renders per event versus renders per frame SSE events Per-event render Frame-aligned render 20 events, one every 5 ms main thread saturated 0 20 40 60 80 100 milliseconds vsync vsync vsync
Per-event rendering does roughly three renders for every frame the display shows. Frame-aligned rendering does exactly one and leaves the rest of the thread free for input.

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.

Where each piece of work runs after the fix Flow from the SSE stream through a merge-only event handler into a coalescing buffer, drained once per animation frame into a single render. Where each piece of work runs after the fix SSE events 200 per second task Handler parse + merge O(1) Buffer latest per key take() rAF callback 60 per second setState One render per frame
Everything left of the buffer runs at the stream's rate and is cheap. Everything right of it runs at the display's rate, however busy the stream is.

Validation & Monitoring Permalink to this section

Validate in the browser, under load, with the tools that show main-thread time.

  1. 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)'
  1. 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.
  2. 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.
  3. 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);
Main-thread time per second at 200 events per second Bar chart comparing main-thread busy time per second for per-event rendering, frame-aligned rendering, and frame-aligned rendering with parsing in a worker. Main-thread time per second at 200 events per second Render per event 940 ms Render per frame 210 ms Per frame + worker parse 120 ms milliseconds of main-thread work per second
Measured on a mid-range laptop with a forty-tile dashboard. Frame alignment gives back most of the thread; a worker takes the remainder once parsing dominates.

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.