A React dashboard that keeps up with a WebSocket feed
Why a live dashboard slows down over time, and the fixes: render once per frame, cap the list, aggregate as events arrive, and plan for reconnects.
By M. Adnan Saleem · · 3 min read
For a vehicle cybersecurity platform I engineered real-time threat detection dashboards in React and TypeScript on WebSocket feeds, which cut analyst time-to-insight by about 20%. A live dashboard has one job that a normal page does not: it must stay responsive while data keeps arriving, for hours. The feed demo on this site shows the same state handling on a simulated feed.
What goes wrong first
The obvious version works in a demo and falls over in production:
socket.onmessage = (message) => {
// One render per message, and a list that grows for as long as the tab is open
setEvents((prev) => [JSON.parse(message.data), ...prev]);
};It has two faults. Every message triggers a render, so a burst of fifty messages is fifty renders in a row. And the list never stops growing, so memory and render time climb all day until the tab is reloaded.
Render per frame, not per message
The screen updates about sixty times a second, so rendering more often than that is wasted work. Collect incoming messages in a buffer and flush it once per animation frame:
useEffect(() => {
const socket = new WebSocket(url);
const buffer = [];
let frame = 0;
const flush = () => {
frame = 0;
const batch = buffer.splice(0).reverse(); // newest first
setEvents((prev) => [...batch, ...prev].slice(0, 40)); // keep what the screen can show
};
socket.onmessage = (message) => {
buffer.push(JSON.parse(message.data));
if (!frame) frame = requestAnimationFrame(flush); // at most one render per frame
};
return () => {
socket.close();
cancelAnimationFrame(frame);
};
}, [url]);A burst of any size now costs one render. The cleanup function matters as much as the handler: without it, every remount leaves a socket open behind it.
Keep only what the screen can show
An analyst reads the latest few dozen events, not the last ten thousand. Cap the list (the demo keeps 40) and leave history to the server, which can page through it on request.
Aggregate as events arrive
Totals and charts should not be recalculated from the list, because the list is capped and recounting is slow. Update them incrementally:
// Counters grow by one per event; no need to recount the list
setCounts((prev) => ({ ...prev, [event.level]: prev[event.level] + 1 }));
// One bucket per second for the chart: add to the newest, drop the oldest every second
setHistory((prev) => [...prev.slice(0, -1), prev[prev.length - 1] + 1]);
setInterval(() => setHistory((prev) => [...prev.slice(1), 0]), 1000);The chart then draws thirty numbers, whatever the event rate.
Plan for the connection dropping
- Reconnect with backoff. Wait one second, then two, then four, up to a ceiling, so a server restart is not met by every client at once.
- Show the state. A dashboard that silently stopped updating is worse than one that says it is reconnecting.
- Catch up. After reconnecting, ask the server for what was missed, using the last event id you saw.
Give the user control
- Pause. Nobody can read a row that keeps moving. A pause button holds the view while events keep buffering.
- Announce politely. Mark the event list with
role="log"so screen readers announce new entries without interrupting. - Make severity more than colour. Pair each colour with a word, so the meaning survives for colour-blind users and in print.
How to tell it holds
Raise the event rate well past what production sends and leave the page open. Frame rate should stay steady and memory should level off, not climb. If either drifts, something is still growing without a cap.
The project behind this
Read the case study
What I built, where, and the numbers that came out of it.