Streaming live sensor data into React without the jank

On the gas-monitoring dashboard, sensors across a dozen facilities push readings over WebSockets several times a second. The naive approach (calling setState on every message) melted the UI. Here's the shape that actually held up.
The problem with setState-per-message
Every socket message triggering a render means React reconciles the whole tree hundreds of times a second. Charts stutter, inputs lag, and the tab heats up.
The fix isn't a faster chart library. It's decoupling how often data arrives from how often the UI renders.
Buffer, then flush on a frame
Collect incoming readings in a ref, and flush them to state once per animation frame:
import { useEffect, useRef, useState } from "react";
export function useLiveReadings(socket: WebSocket) {
const buffer = useRef<Reading[]>([]);
const [readings, setReadings] = useState<Reading[]>([]);
useEffect(() => {
const onMessage = (e: MessageEvent) => {
buffer.current.push(JSON.parse(e.data));
};
socket.addEventListener("message", onMessage);
let frame = requestAnimationFrame(function flush() {
if (buffer.current.length) {
setReadings((prev) => merge(prev, buffer.current));
buffer.current = [];
}
frame = requestAnimationFrame(flush);
});
return () => {
socket.removeEventListener("message", onMessage);
cancelAnimationFrame(frame);
};
}, [socket]);
return readings;
}Now the socket can fire as fast as it likes; the UI renders at most once per frame.
Keep the heavy work off the main thread
Parsing and aggregating large batches still blocks paint. Move it to a worker:
const worker = new Worker(new URL("./aggregate.worker.ts", import.meta.url));
worker.postMessage(batch);
worker.onmessage = (e) => setSeries(e.data);What actually moved the needle
- Batch by frame: the single biggest win.
- Memoize chart data so unrelated state changes don't recompute series.
- Virtualize long tables: only render rows in view.
- Throttle non-critical widgets to once a second; not everything needs 60fps.
The result: smooth charts under a firehose of updates, and a main thread free enough to stay interactive.