How to Build Real-Time Dashboards That Don't Kill Performance
WebSockets, virtualization, and caching patterns for data-heavy UIs
I once inherited a dashboard that took 11 seconds to render 500 rows of data. The previous team's solution? Pagination. "Show 20 rows at a time." The client hated it — they needed to see trends across all their data at once. That's when I learned: building fast dashboards isn't about showing less data. It's about rendering it smarter.
Here's everything I've learned from building real-time dashboards for eCommerce analytics, industrial IoT monitoring, and enterprise meeting intelligence platforms.
The Three Performance Killers
Every slow dashboard I've debugged had one (or more) of these problems:
1. Re-Rendering the Entire Dashboard on Every Data Update
WebSocket sends a new price tick. Your top-level state updates. Every chart, every table, every stat card re-renders. 60 times per second. This is the #1 dashboard performance killer.
The fix: Isolate state updates.
// BAD: One big state object
const [dashboardData, setDashboardData] = useState({
stats: {},
tableRows: [],
chartData: [],
alerts: [],
});
// GOOD: Separate stores per concern
const useStatsStore = create((set) => ({
stats: {},
updateStats: (stats) => set({ stats }),
}));
const useTableStore = create((set) => ({
rows: [],
updateRows: (rows) => set({ rows }),
}));
When a WebSocket message arrives with new stats, only the stats components re-render. The table and charts stay untouched.
2. Rendering Thousands of DOM Nodes
A table with 1,000 rows × 10 columns = 10,000 DOM nodes. Add cells with formatted numbers, status badges, and action buttons, and you're at 30,000+ nodes. The browser's rendering engine was not designed for this.
The fix: Virtualization.
Only render the rows visible in the viewport. A 1,000-row table becomes a 20-row table (plus buffer) that swaps content as the user scrolls.
import { useVirtualizer } from "@tanstack/react-virtual";
function VirtualizedTable({ rows }: { rows: DataRow[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 48,
overscan: 10,
});
return (
<div ref={parentRef} className="h-[600px] overflow-auto">
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
const row = rows[virtualRow.index];
return (
<div
key={row.id}
style={{
position: "absolute",
top: virtualRow.start,
height: virtualRow.size,
width: "100%",
}}
>
<TableRow data={row} />
</div>
);
})}
</div>
</div>
);
}
From 10,000 DOM nodes to ~200. Scrolling stays smooth at 60fps.
3. Recalculating Chart Data on Every Render
Every time your chart component renders, it maps, filters, and reduces an array of 10,000 data points to compute the chart series. Even if the data hasn't changed.
The fix: Memoize aggressively.
const chartSeries = useMemo(() => {
return rawData.map((point) => ({
x: new Date(point.timestamp),
y: point.value,
}));
}, [rawData]);
const MemoizedChart = memo(function RevenueChart({ data }: { data: ChartPoint[] }) {
return <Recharts.LineChart data={data}>...</Recharts.LineChart>;
});
WebSocket Architecture
For real-time dashboards, the WebSocket connection is the nervous system. Get it wrong and everything suffers.
The Connection Manager Pattern
class DashboardSocket {
private ws: WebSocket | null = null;
private reconnectAttempts = 0;
private maxReconnectAttempts = 5;
private handlers = new Map<string, Set<(data: unknown) => void>>();
connect(url: string) {
this.ws = new WebSocket(url);
this.ws.onmessage = (event) => {
const { type, payload } = JSON.parse(event.data);
const typeHandlers = this.handlers.get(type);
if (typeHandlers) {
typeHandlers.forEach((handler) => handler(payload));
}
};
this.ws.onclose = () => {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 30000);
setTimeout(() => {
this.reconnectAttempts++;
this.connect(url);
}, delay);
}
};
}
subscribe(type: string, handler: (data: unknown) => void) {
if (!this.handlers.has(type)) {
this.handlers.set(type, new Set());
}
this.handlers.get(type)!.add(handler);
return () => {
this.handlers.get(type)?.delete(handler);
};
}
}
Throttle Incoming Updates
If your server sends 60 updates per second but your chart only needs to redraw once per second, you're wasting 59 renders.
function useThrottledWebSocket(type: string, intervalMs: number) {
const [data, setData] = useState(null);
const bufferRef = useRef(null);
useEffect(() => {
const unsubscribe = socket.subscribe(type, (payload) => {
bufferRef.current = payload;
});
const interval = setInterval(() => {
if (bufferRef.current) {
setData(bufferRef.current);
bufferRef.current = null;
}
}, intervalMs);
return () => {
unsubscribe();
clearInterval(interval);
};
}, [type, intervalMs]);
return data;
}
// Usage: chart updates once per second, not 60 times
const chartData = useThrottledWebSocket("price-tick", 1000);
Caching Strategies
Real-time doesn't mean every piece of data needs to be live. Smart caching is the difference between a dashboard that feels instant and one that feels sluggish.
Layer Your Cache
- In-memory cache for data that changes every few seconds (latest metrics)
- TanStack Query cache for data that changes every few minutes (aggregations, summaries)
- localStorage for data that changes rarely (user preferences, dashboard layout, filter presets)
Stale-While-Revalidate for Aggregations
// Show cached data immediately, refetch in background
const { data: dailyStats } = useQuery({
queryKey: ["stats", "daily", selectedDate],
queryFn: () => api.getDailyStats(selectedDate),
staleTime: 60_000, // Consider fresh for 1 minute
gcTime: 5 * 60_000, // Keep in cache for 5 minutes
placeholderData: keepPreviousData,
});
The dashboard shows the previous day's stats immediately while fetching the current day's data. No loading spinners, no layout shifts.
The Architecture That Scales
After building multiple real-time dashboards, here's the architecture I always end up at:
- WebSocket connection manager — singleton, handles reconnection, routes messages
- Per-widget Zustand stores — each dashboard widget owns its state
- TanStack Query — for REST API data (initial load, historical data, aggregations)
- Virtualization — for any list or table with 50+ items
- Memoized chart components — charts only re-render when their specific data changes
- Throttled updates — match update frequency to visual refresh rate
This architecture handles thousands of concurrent data points at 60fps. I've used it for eCommerce analytics dashboards with 50+ real-time metrics and industrial IoT monitoring with sensor data streaming from hundreds of devices.
The key insight: treat real-time data as a stream that you sample, not a firehose that you render. Your users can't perceive updates faster than ~200ms anyway. Design your rendering pipeline around human perception, not raw data throughput.