Real-Time Monitoring with React

How to build real-time monitoring dashboards in React with WebSocket data feeds, live charts, auto-refresh patterns, and efficient rendering for high-frequency updates.

technical5 min readBy Klivvr Engineering
Share:

Operations dashboards must show the current state of the system, not the state from five minutes ago. During an incident, the difference between real-time and near-real-time visibility is the difference between catching a problem as it develops and discovering it after it has cascaded.

The Web Ops Console uses WebSocket connections to stream real-time metrics, log entries, and alerts to the dashboard. This article covers the patterns for consuming real-time data in React while maintaining smooth performance.

WebSocket Data Provider

A React context provider manages the WebSocket connection lifecycle and distributes incoming messages to subscribed components.

import { createContext, useContext, useEffect, useRef, useState, useCallback } from "react";
 
interface WSMessage {
  channel: string;
  data: unknown;
  timestamp: string;
}
 
interface WSContextValue {
  isConnected: boolean;
  subscribe: (channel: string, handler: (data: unknown) => void) => () => void;
  send: (message: unknown) => void;
}
 
const WSContext = createContext<WSContextValue | null>(null);
 
function WebSocketProvider({ url, children }: {
  url: string;
  children: React.ReactNode;
}) {
  const wsRef = useRef<WebSocket | null>(null);
  const handlersRef = useRef(new Map<string, Set<(data: unknown) => void>>());
  const [isConnected, setIsConnected] = useState(false);
 
  useEffect(() => {
    function connect() {
      const ws = new WebSocket(url);
 
      ws.onopen = () => setIsConnected(true);
      ws.onclose = () => {
        setIsConnected(false);
        setTimeout(connect, 3000);
      };
 
      ws.onmessage = (event) => {
        const message: WSMessage = JSON.parse(event.data);
        const handlers = handlersRef.current.get(message.channel);
        if (handlers) {
          handlers.forEach((handler) => handler(message.data));
        }
      };
 
      wsRef.current = ws;
    }
 
    connect();
    return () => wsRef.current?.close();
  }, [url]);
 
  const subscribe = useCallback(
    (channel: string, handler: (data: unknown) => void) => {
      if (!handlersRef.current.has(channel)) {
        handlersRef.current.set(channel, new Set());
      }
      handlersRef.current.get(channel)!.add(handler);
 
      return () => {
        handlersRef.current.get(channel)?.delete(handler);
      };
    },
    []
  );
 
  const send = useCallback((message: unknown) => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(JSON.stringify(message));
    }
  }, []);
 
  return (
    <WSContext.Provider value={{ isConnected, subscribe, send }}>
      {children}
    </WSContext.Provider>
  );
}
 
function useWebSocket() {
  const context = useContext(WSContext);
  if (!context) throw new Error("useWebSocket must be used within WSProvider");
  return context;
}
 
function useChannel<T>(channel: string): T | null {
  const { subscribe } = useWebSocket();
  const [data, setData] = useState<T | null>(null);
 
  useEffect(() => {
    return subscribe(channel, (incoming) => setData(incoming as T));
  }, [channel, subscribe]);
 
  return data;
}

Live Metric Display

Real-time metrics update frequently — CPU usage, request rate, error count. The rendering must be efficient to handle updates every second without janking the UI.

interface LiveMetric {
  value: number;
  unit: string;
  status: "normal" | "warning" | "critical";
}
 
function LiveMetricCard({ channel, label }: {
  channel: string;
  label: string;
}) {
  const metric = useChannel<LiveMetric>(channel);
  const prevValueRef = useRef<number>(0);
 
  const statusColors = {
    normal: "text-green-600 bg-green-50",
    warning: "text-yellow-600 bg-yellow-50",
    critical: "text-red-600 bg-red-50",
  };
 
  if (!metric) return <MetricSkeleton />;
 
  const trend = metric.value > prevValueRef.current ? "up" : "down";
  prevValueRef.current = metric.value;
 
  return (
    <div className={`rounded-lg p-4 ${statusColors[metric.status]}`}>
      <div className="text-xs font-medium uppercase tracking-wide opacity-75">
        {label}
      </div>
      <div className="mt-1 flex items-baseline gap-1">
        <span className="text-2xl font-bold">
          {formatNumber(metric.value)}
        </span>
        <span className="text-sm opacity-75">{metric.unit}</span>
        <TrendArrow direction={trend} />
      </div>
    </div>
  );
}

Streaming Charts

Live charts that display time-series data require careful buffer management. Each data point is appended to a rolling window, and old data is pruned to prevent unbounded memory growth.

import { useRef, useEffect, useState, useCallback } from "react";
 
function useTimeSeriesBuffer(channel: string, maxPoints: number = 300) {
  const { subscribe } = useWebSocket();
  const bufferRef = useRef<Array<{ time: number; value: number }>>([]);
  const [data, setData] = useState<Array<{ time: number; value: number }>>([]);
 
  useEffect(() => {
    const unsubscribe = subscribe(channel, (incoming: unknown) => {
      const point = incoming as { time: number; value: number };
      bufferRef.current.push(point);
 
      // Prune old data
      if (bufferRef.current.length > maxPoints) {
        bufferRef.current = bufferRef.current.slice(-maxPoints);
      }
    });
 
    // Batch UI updates at 1fps to avoid excessive re-renders
    const updateInterval = setInterval(() => {
      setData([...bufferRef.current]);
    }, 1000);
 
    return () => {
      unsubscribe();
      clearInterval(updateInterval);
    };
  }, [channel, maxPoints, subscribe]);
 
  return data;
}
 
function LiveChart({ channel, title, color }: {
  channel: string;
  title: string;
  color: string;
}) {
  const data = useTimeSeriesBuffer(channel);
 
  return (
    <div className="rounded-lg border bg-white p-4">
      <h3 className="mb-3 text-sm font-medium text-gray-700">{title}</h3>
      <div className="h-48">
        <LineChart
          data={data}
          xKey="time"
          yKey="value"
          color={color}
          animate={false}
          xFormatter={(t) => new Date(t).toLocaleTimeString()}
        />
      </div>
    </div>
  );
}

The key optimization is decoupling the data arrival rate from the render rate. Data arrives via WebSocket at potentially high frequency (10+ messages per second). But the chart renders at 1 fps by batching updates — smooth enough for visual monitoring without wasting render cycles.

Connection Status Indicator

Operations teams must know if they are seeing live data or stale data. A connection status indicator is essential.

function ConnectionStatus() {
  const { isConnected } = useWebSocket();
 
  return (
    <div className="flex items-center gap-2 text-sm">
      <div
        className={`h-2 w-2 rounded-full ${
          isConnected ? "bg-green-500 animate-pulse" : "bg-red-500"
        }`}
      />
      <span className={isConnected ? "text-green-700" : "text-red-700"}>
        {isConnected ? "Live" : "Disconnected — reconnecting..."}
      </span>
    </div>
  );
}

Service Health Grid

A common ops dashboard view is a grid showing the health status of all services at a glance.

interface ServiceHealth {
  name: string;
  status: "healthy" | "degraded" | "down";
  uptime: number;
  latencyP99: number;
  errorRate: number;
  lastChecked: string;
}
 
function ServiceHealthGrid() {
  const services = useChannel<ServiceHealth[]>("services.health");
 
  if (!services) return <GridSkeleton count={12} />;
 
  return (
    <div className="grid grid-cols-4 gap-3">
      {services.map((service) => (
        <div
          key={service.name}
          className={`rounded-lg border p-3 ${
            service.status === "healthy"
              ? "border-green-200 bg-green-50"
              : service.status === "degraded"
              ? "border-yellow-200 bg-yellow-50"
              : "border-red-200 bg-red-50"
          }`}
        >
          <div className="flex items-center justify-between">
            <span className="text-sm font-medium">{service.name}</span>
            <StatusBadge status={service.status} />
          </div>
          <div className="mt-2 grid grid-cols-2 gap-x-4 text-xs text-gray-600">
            <div>P99: {service.latencyP99}ms</div>
            <div>Errors: {(service.errorRate * 100).toFixed(2)}%</div>
            <div>Uptime: {(service.uptime * 100).toFixed(1)}%</div>
          </div>
        </div>
      ))}
    </div>
  );
}

Conclusion

Real-time monitoring in React requires careful architecture: WebSocket providers for connection management, channel-based subscriptions for targeted data distribution, buffer management for time-series data, render-rate decoupling from data-rate for performance, and clear connection status indicators. The Web Ops Console combines these patterns to provide Klivvr's operations team with live visibility into every service — ensuring that when something goes wrong, the team sees it immediately and can respond before customers are affected.

Related Articles

business

Build vs Buy for Internal Operations Tools

A framework for deciding whether to build or buy internal operations tools, covering total cost of ownership, customization needs, and the strategic value of purpose-built tooling.

6 min read
technical

Data Visualization Patterns for Ops Dashboards

How to choose and implement effective data visualizations for operations dashboards, covering chart selection, color systems, responsive layouts, and accessibility.

5 min read