Building a Real-Time Engineering Monitor with WebSockets
Design a typed WebSocket message protocol, build a resilient useLiveMonitor hook with automatic reconnection and exponential backoff, render a connection-status indicator, and render a large scrolling history efficiently with windowing.
Engineering monitoring dashboards — CI pipelines, sensor telemetry, server health panels — all share the same hard problem: a live stream of heterogeneous events has to be received, typed, buffered, and rendered without freezing the tab or losing the thread when the network hiccups. In this project you'll build the three pillars of any serious real-time UI: a message protocol you can trust at compile time, a connection lifecycle that survives drops and reconnects on its own, and a rendering strategy that stays smooth even when the history list grows into the thousands. Everything here generalizes past this one project — the same hook shape works for chat apps, trading tickers, and multiplayer games.
Designing a typed message protocol
The single biggest source of bugs in WebSocket UIs is treating every incoming frame as any. A raw WebSocket gives you event.data as a string — nothing stops the server from sending a shape you didn't expect, and nothing helps the compiler catch a typo in a field name six components downstream. The fix is a discriminated union: one type field that TypeScript can narrow on, and a runtime guard that validates untrusted JSON before it ever touches application state.
Treat the wire format as a versioned contract. Every message carries a type, a monotonic seq (so you can detect gaps or reordering), and a timestamp. Client-to-server and server-to-client messages get separate unions so a component can never accidentally construct a message meant to flow the other way.
• A discriminated union keyed on type gives you exhaustive switch handling with no any. • A parseServerMessage guard runs at the network boundary — nothing downstream trusts raw JSON. • A seq number lets the client detect dropped or out-of-order frames after a reconnect. • Keeping request and response unions separate prevents type confusion between directions.
The connection lifecycle: reconnect, backoff, and error state
A raw new WebSocket(url) gives you four events — open, message, close, error — and nothing else. Production code needs more: automatic reconnection when the connection drops unexpectedly, exponential backoff so a flapping server doesn't get hammered with reconnect attempts, a cap on retry delay, and a way to distinguish 'still trying' from 'gave up' from 'user asked to disconnect'. All of that belongs in one hook so components stay declarative.
The key design decision is to model connection status as an explicit enum rather than a couple of booleans. 'connecting' | 'open' | 'reconnecting' | 'closed' | 'error' is exhaustive and makes the status indicator component a pure function of state — no derived boolean logic scattered across the tree.
Reconnection must be driven by a ref-held timer, not by state, because you don't want a re-render to cancel or duplicate a pending retry. The hook also needs to clean up its socket and timers on unmount, and it needs to reset the backoff counter the moment a connection successfully opens — otherwise a single failure after a long healthy session would start backoff from the same inflated delay as the very first failure.
Rendering thousands of history entries without jank
A monitor that has been running for an hour can accumulate tens of thousands of events. Rendering every <li> in the DOM at once destroys scroll performance and can visibly stutter incoming updates. The fix is windowing (also called virtualization): only render the DOM nodes for the rows currently in or near the viewport, and use spacer elements to preserve correct scrollbar size and position for the rows you're not rendering.
You don't need a heavyweight library to get the core benefit. A fixed row height plus the container's scrollTop and clientHeight is enough to compute a visible index range with plain arithmetic. Pair that with capping the in-memory history array itself (e.g., keep the last 5,000 entries) so memory doesn't grow unbounded during a long session.
The message protocol and connection-status type
The type field is the discriminant — TypeScript narrows the union whenever you check it. seq and timestamp are on every message so gap-detection and ordering logic never special-cases a variant. Client and server messages live in separate unions so a component can't accidentally send a server-shaped object.
// protocol.ts — the wire contract, shared between client and (conceptually) server
export type ConnectionStatus =
| "idle"
| "connecting"
| "open"
| "reconnecting"
| "closed"
| "error";
export interface MetricUpdate {
type: "metric_update";
seq: number;
timestamp: number;
metricId: string;
value: number;
unit: string;
}
export interface StatusChange {
type: "status_change";
seq: number;
timestamp: number;
serviceId: string;
status: "healthy" | "degraded" | "down";
}
export interface AlertRaised {
type: "alert_raised";
seq: number;
timestamp: number;
alertId: string;
severity: "info" | "warning" | "critical";
message: string;
}
export interface Heartbeat {
type: "heartbeat";
seq: number;
timestamp: number;
}
// Discriminated union: exhaustive switches, no "any" anywhere downstream.
export type ServerMessage = MetricUpdate | StatusChange | AlertRaised | Heartbeat;
export interface SubscribeRequest {
type: "subscribe";
channels: string[];
}
export type ClientMessage = SubscribeRequest;Code Examples
TypeScript's structural type system disappears at the network boundary — JSON.parse returns any. This guard is the one place that re-establishes trust: every field is checked at runtime, and a malformed frame is dropped instead of silently corrupting state or crashing a component with a 'reading property of undefined' error.
function isServerMessage(value: unknown): value is ServerMessage {
Common mistake: Casting JSON.parse(event.data) as ServerMessage without a runtime check — the cast compiles fine and lies to you the first time the server sends something unexpected.
The hook keeps the retry counter and timer in refs, not state, so a reconnect schedule survives re-renders without re-triggering effects. Backoff doubles each failed attempt up to a 15-second ceiling, and a successful onopen resets the counter to zero so a brief blip after hours of uptime doesn't inherit a huge delay. manualCloseRef is what distinguishes 'the server dropped us' (reconnect) from 'the component wants to disconnect' (don't reconnect). History is capped at MAX_HISTORY entries with a slice, which bounds memory for long-running sessions while still keeping the freshest data.
const delay = Math.min(BASE_DELAY_MS * 2 ** attemptRef.current, MAX_DELAY_MS);
The whole component is a lookup table plus JSX — no branching logic to maintain, and adding a new status later means adding one row to CONFIG. role="status" with aria-live="polite" announces status changes to screen reader users without needing a focus change, which matters a great deal for a dashboard people leave open in a background tab.
const { label, color, pulse } = CONFIG[status];
The outer div is the scroll container; the inner div is sized to the FULL history height so the scrollbar behaves correctly even though only a handful of rows are actually mounted. Rows are absolutely positioned by index × row height, so only endIndex - startIndex DOM nodes ever exist regardless of whether history has 50 or 50,000 entries. Overscan renders a few extra rows above and below the viewport so fast scrolling doesn't flash empty space.
const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
Common mistake: Rendering history.map(...) directly with no windowing — it works fine in a demo with 20 rows and then silently ruins frame rate once real traffic arrives.
// parseServerMessage.ts
import type { ServerMessage } from "./protocol";
function isServerMessage(value: unknown): value is ServerMessage {
if (typeof value !== "object" || value === null) return false;
const v = value as Record<string, unknown>;
if (typeof v.type !== "string" || typeof v.seq !== "number" || typeof v.timestamp !== "number") {
return false;
}
switch (v.type) {
case "metric_update":
return typeof v.metricId === "string" && typeof v.value === "number" && typeof v.unit === "string";
case "status_change":
return typeof v.serviceId === "string" &&
(v.status === "healthy" || v.status === "degraded" || v.status === "down");
case "alert_raised":
return typeof v.alertId === "string" && typeof v.message === "string" &&
(v.severity === "info" || v.severity === "warning" || v.severity === "critical");
case "heartbeat":
return true;
default:
return false;
}
}
export function parseServerMessage(raw: string): ServerMessage | null {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
return isServerMessage(parsed) ? parsed : null;
}// useLiveMonitor.ts
import { useCallback, useEffect, useRef, useState } from "react";
import { parseServerMessage } from "./parseServerMessage";
import type { ConnectionStatus, ServerMessage } from "./protocol";
const MAX_HISTORY = 5000;
const BASE_DELAY_MS = 500;
const MAX_DELAY_MS = 15000;
interface UseLiveMonitorResult {
status: ConnectionStatus;
history: ServerMessage[];
lastError: string | null;
reconnectAttempt: number;
disconnect: () => void;
reconnectNow: () => void;
}
export function useLiveMonitor(url: string, channels: string[]): UseLiveMonitorResult {
const [status, setStatus] = useState<ConnectionStatus>("idle");
const [history, setHistory] = useState<ServerMessage[]>([]);
const [lastError, setLastError] = useState<string | null>(null);
const [reconnectAttempt, setReconnectAttempt] = useState(0);
const socketRef = useRef<WebSocket | null>(null);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const attemptRef = useRef(0);
const manualCloseRef = useRef(false);
const lastSeqRef = useRef(-1);
const clearTimer = () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.curren
…// ConnectionIndicator.tsx
import type { ConnectionStatus } from "./protocol";
const CONFIG: Record<ConnectionStatus, { label: string; color: string; pulse: boolean }> = {
idle: { label: "Idle", color: "#94a3b8", pulse: false },
connecting: { label: "Connecting…", color: "#f59e0b", pulse: true },
open: { label: "Live", color: "#22c55e", pulse: false },
reconnecting: { label: "Reconnecting…", color: "#f59e0b", pulse: true },
closed: { label: "Disconnected", color: "#94a3b8", pulse: false },
error: { label: "Connection error", color: "#ef4444", pulse: false },
};
interface ConnectionIndicatorProps {
status: ConnectionStatus;
reconnectAttempt: number;
onReconnect: () => void;
}
export function ConnectionIndicator({ status, reconnectAttempt, onReconnect }: ConnectionIndicatorProps) {
const { label, color, pulse } = CONFIG[status];
const showRetry = status === "error" || status === "closed";
return (
<div role="status" aria-live="polite" style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span
aria-hidden="true"
style={{
width: 10,
height: 10,
borderRadius: "50%",
backgroundColor: color
…// VirtualizedHistory.tsx
import { useMemo, useRef, useState } from "react";
import type { ServerMessage } from "./protocol";
const ROW_HEIGHT = 32;
const OVERSCAN = 6;
interface VirtualizedHistoryProps {
history: ServerMessage[];
height: number;
}
export function VirtualizedHistory({ history, height }: VirtualizedHistoryProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
const totalHeight = history.length * ROW_HEIGHT;
const visibleCount = Math.ceil(height / ROW_HEIGHT);
const startIndex = Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - OVERSCAN);
const endIndex = Math.min(history.length, startIndex + visibleCount + OVERSCAN * 2);
const visibleRows = useMemo(
() => history.slice(startIndex, endIndex).map((msg, i) => ({ msg, index: startIndex + i })),
[history, startIndex, endIndex],
);
return (
<div
ref={containerRef}
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
style={{ height, overflowY: "auto", position: "relative", border: "1px solid #334155" }}
>
<div style={{ height: totalHeight, position: "relative" }}>
{visibleRows.map(({ msg, in
…Data flow from socket to screen
Untrusted bytes are validated once at the boundary; everything downstream works with a trusted, narrow type.
WebSocket frame (string)
│
▼
parseServerMessage() ── invalid JSON / shape ──▶ dropped, no state change
│ valid ServerMessage
▼
useLiveMonitor state ── status, history[], lastError
│
┌──────┴───────────────┐
▼ ▼
ConnectionIndicator VirtualizedHistory
(reads status) (reads history, renders only visible rows)Important Rules
Never trust JSON.parse(event.data) as a typed value — validate it with a runtime guard before storing it in state.
Model connection status as one exhaustive enum, not scattered booleans like isConnected/isReconnecting.
Keep reconnect timers and attempt counters in refs so re-renders can't duplicate or cancel a pending retry.
Reset backoff to its base delay on every successful onopen, not just once at mount.
Cap in-memory history and window the DOM for any list that can grow past a few hundred rows.
Always clean up the socket and any pending timers in the effect's cleanup function to avoid leaks across remounts.
Where this pattern shows up in production
This exact shape — typed protocol, backoff-driven reconnect hook, status indicator, windowed feed — is what powers CI dashboards (build status streaming), trading terminals (price ticks), multiplayer game lobbies (player state), and observability tools like Grafana Live or Datadog's real-time views. The names change; the architecture (validate at the boundary, isolate connection lifecycle in a hook, window the render) does not.
socket.onclose = () => { setTimeout(connect, 1000); } socket.onclose = () => { const delay = Math.min(BASE_DELAY_MS * 2 ** attemptRef.current, MAX_DELAY_MS); attemptRef.current += 1; timerRef.current = setTimeout(connect, delay); }
A fixed 1-second retry hammers a struggling server with reconnect storms from every open client simultaneously. Exponential backoff with a ceiling spreads out retries and gives the server room to recover.
useEffect(() => { connect(); }, [url, channels]);
useEffect(() => { connect(); }, [connect]); // connect depends only on url; resubscribe channels via a separate send, not a reconnect
Including a new array literal like channels in a dependency list re-runs the effect (and reconnects the socket) on every render where the parent recreates that array, even if its contents didn't meaningfully change.
Why should the reconnect timer and attempt counter live in refs instead of component state? A. Refs are faster to read than state in every case B. So a re-render cannot cancel or duplicate a pending reconnect timer, since ref writes don't trigger effects C. State cannot hold numbers, only strings and booleans D. Refs automatically persist across page reloads
B. So a re-render cannot cancel or duplicate a pending reconnect timer, since ref writes don't trigger effects — State updates trigger re-renders and can re-run effects depending on dependencies; keeping the timer/attempt tracking in refs isolates the reconnect schedule from the render cycle so it isn't accidentally reset or duplicated.
What is the main risk of casting JSON.parse(event.data) as ServerMessage without a runtime guard? A. It throws a compile error B. It is slower than using a guard function C. TypeScript's type is a compile-time-only promise; an unexpected server payload can silently corrupt state or crash a component with no type error D. WebSocket messages cannot be JSON
C. TypeScript's type is a compile-time-only promise; an unexpected server payload can silently corrupt state or crash a component with no type error — A type assertion doesn't check anything at runtime — it just tells the compiler to trust you. If the actual data doesn't match, the mismatch surfaces later as a runtime crash or subtly wrong UI, with no warning at the cast site.
In the windowed history list, why is the inner content div given a height equal to the FULL history length times row height, even though most rows aren't rendered? A. To make the animation smoother B. So the browser's scrollbar size and scroll position behave correctly, as if every row were actually present C. It's required by React's virtual DOM diffing algorithm D. To prevent memory leaks in the WebSocket connection
B. So the browser's scrollbar size and scroll position behave correctly, as if every row were actually present — The scrollable area's total height must match what the FULL, unwindowed list would occupy so the scrollbar thumb size and scroll position are accurate; only the visible slice of rows is actually mounted, positioned absolutely within that full-height container.
Extend the monitor: add a performance-metrics panel that subscribes to a 'metric_update' channel and renders a live sparkline per metricId using the windowed history you already have; add an error-state banner that appears when lastError is set and offers a 'Retry now' action wired to reconnectNow; and add message coalescing so that if more than 20 metric_update messages for the same metricId arrive within 100ms, only the latest is applied to state (to avoid excessive re-renders during a burst). Hint: For coalescing, buffer incoming metric_update messages in a ref keyed by metricId and flush the ref into state on a fixed interval (e.g., via requestAnimationFrame or a 100ms setInterval) rather than calling setHistory synchronously inside onmessage.
Try it yourself — there's no single right answer here; compare your result against this lesson's examples.