Real-Time Dashboards with Socket.IO and React: Patterns That Survive Production

Every Socket.IO tutorial shows you socket.on("event", setState) inside a useEffect. That works until the connection drops, the user navigates, or the server sends faster than React can render — and then it fails in ways that are invisible in development. What follows is the complete set of patterns from an aircraft operations dashboard I owned for ten months, with the code as it actually shipped rather than trimmed for illustration.
The problem with naive real-time
Adding Socket.IO to a React app looks trivial in a demo: connect, listen, set state. In production — I built an aircraft operations dashboard that lived on this stack for ten months — the naive version falls apart on reconnection storms, duplicate listeners after hot navigation, and state that drifts from the server after a missed event. The patterns below are what survived.
One connection, owned outside React
The socket connection does not belong inside a component. Components mount and unmount with navigation; your connection should not. I keep a singleton socket module — created once, imported anywhere — and expose subscription hooks on top of it:
// lib/socket.ts
import { io, type Socket } from "socket.io-client";
let socket: Socket | null = null;
export function getSocket(): Socket {
if (socket) return socket;
socket = io(process.env.NEXT_PUBLIC_WS_URL as string, {
autoConnect: false,
transports: ["websocket"],
reconnection: true,
reconnectionAttempts: Infinity,
reconnectionDelay: 1000,
reconnectionDelayMax: 10000,
// Callback form, not a static object: this runs again on every reconnect,
// so a token refreshed since the first connect is picked up automatically.
auth: (cb) => cb({ token: localStorage.getItem("access_token") ?? "" }),
});
return socket;
}
export function connectSocket(): Socket {
const s = getSocket();
if (!s.connected) s.connect();
return s;
}
export function disconnectSocket(): void {
socket?.disconnect();
}The auth callback deserves the comment it carries. Passing a plain object captures whatever token existed at module load, so after an hour-long session every reconnect attempt authenticates with an expired credential and fails silently in a retry loop. The callback form re-reads the token each attempt. That single character difference cost me an afternoon of debugging a dashboard that "stopped updating after lunch".
React then consumes it through a hook that subscribes on mount, unsubscribes on unmount, and never owns the connection lifecycle. That separation eliminated an entire class of duplicate-listener bugs.
How do you subscribe to socket events from a component?
The naive hook resubscribes on every render, because the handler is usually an inline arrow function with a new identity each time. Under React 18's development double-mount you then get duplicate listeners immediately, and in production you get them whenever a parent re-renders. Holding the handler in a ref fixes both:
// hooks/useSocketEvent.ts
import { useEffect, useRef } from "react";
import { getSocket } from "@/lib/socket";
export function useSocketEvent<T>(event: string, handler: (payload: T) => void): void {
const handlerRef = useRef(handler);
// Keep the ref current without making it a dependency of the subscription.
useEffect(() => {
handlerRef.current = handler;
}, [handler]);
useEffect(() => {
const socket = getSocket();
const listener = (payload: T) => handlerRef.current(payload);
socket.on(event, listener);
return () => {
socket.off(event, listener);
};
}, [event]);
}The subscription now depends only on the event name, so it is set up once per mount regardless of how often the component renders. Note socket.off(event, listener) with the specific listener rather than socket.off(event) — the latter removes every subscriber for that event, including other components' listeners, which produces a bug that only appears when two features listen to the same channel.
How do you handle reconnection properly?
Socket.IO reconnects on its own; what it cannot do is tell you what you missed. Reconnection handling is therefore two jobs: surfacing the state to the user, and triggering a resync. Here is the status hook, complete:
// hooks/useConnectionStatus.ts
import { useEffect, useState } from "react";
import { getSocket } from "@/lib/socket";
export type ConnectionStatus = "connecting" | "connected" | "reconnecting" | "offline";
export function useConnectionStatus(): ConnectionStatus {
const [status, setStatus] = useState<ConnectionStatus>(() =>
getSocket().connected ? "connected" : "connecting"
);
useEffect(() => {
const socket = getSocket();
const onConnect = () => setStatus("connected");
const onDisconnect = (reason: string) => {
// An explicit server-side or client-side disconnect will not auto-retry.
setStatus(reason === "io server disconnect" || reason === "io client disconnect"
? "offline"
: "reconnecting");
};
const onReconnectAttempt = () => setStatus("reconnecting");
const onReconnectFailed = () => setStatus("offline");
socket.on("connect", onConnect);
socket.on("disconnect", onDisconnect);
socket.io.on("reconnect_attempt", onReconnectAttempt);
socket.io.on("reconnect_failed", onReconnectFailed);
return () => {
socket.off("connect", onConnect);
socket.off("disconnect", onDisconnect);
socket.io.off("reconnect_attempt", onReconnectAttempt);
socket.io.off("reconnect_failed", onReconnectFailed);
};
}, []);
return status;
}Two details that are easy to miss. Reconnection events live on socket.io (the manager), not on socket itself — subscribing to "reconnect_attempt" on the socket silently never fires. And the disconnect reason matters: "io server disconnect" means the server deliberately closed the connection and Socket.IO will *not* retry, so treating every disconnect as temporary leaves the user watching a "reconnecting" spinner forever.
Reconciliation beats accumulation
The tempting pattern — append every incoming event to local state — drifts the moment a packet is missed. For the flight dashboard, every reconnect triggered a full snapshot fetch over REST, and socket events only patched state between snapshots. Server state stayed authoritative:
- On connect or reconnect: fetch snapshot, replace state
- On event: apply patch optimistically
- On visibility change after sleep: re-snapshot, because laptops close lids
This "snapshot plus patches" model cut our data-latency complaints to zero and reduced processing latency by half compared to the previous accumulate-and-hope approach. In full:
// hooks/useFlightState.ts
import { useCallback, useEffect, useRef, useState } from "react";
import { getSocket } from "@/lib/socket";
import { useSocketEvent } from "./useSocketEvent";
export interface Flight {
id: string;
callsign: string;
lat: number;
lon: number;
altitude: number;
status: "scheduled" | "airborne" | "landed";
}
export function useFlightState() {
const [flights, setFlights] = useState<Map<string, Flight>>(new Map());
const [syncedAt, setSyncedAt] = useState<Date | null>(null);
// Guards against a slow snapshot response overwriting newer patch data.
const requestId = useRef(0);
const resync = useCallback(async () => {
const id = ++requestId.current;
const res = await fetch("/api/flights", { cache: "no-store" });
if (!res.ok) return;
const data: Flight[] = await res.json();
if (id !== requestId.current) return; // a newer resync already won
setFlights(new Map(data.map((f) => [f.id, f])));
setSyncedAt(new Date());
}, []);
// Snapshot on first connect and on every reconnect.
useEffect(() => {
const socket = getSocket();
socket.on("connect", resync);
if (socket.connected) void resync();
return () => {
socket.off("connect", resync);
};
}, [resync]);
// Laptops close lids; tabs sleep. Both produce silently stale data.
useEffect(() => {
const onVisible = () => {
if (document.visibilityState === "visible") void resync();
};
document.addEventListener("visibilitychange", onVisible);
return () => document.removeEventListener("visibilitychange", onVisible);
}, [resync]);
// Patches between snapshots.
useSocketEvent<Flight>("flight:update", (flight) => {
setFlights((prev) => {
const next = new Map(prev);
next.set(flight.id, { ...next.get(flight.id), ...flight });
return next;
});
});
useSocketEvent<{ id: string }>("flight:remove", ({ id }) => {
setFlights((prev) => {
if (!prev.has(id)) return prev; // no re-render for a no-op
const next = new Map(prev);
next.delete(id);
return next;
});
});
return { flights, syncedAt, resync };
}Three decisions in there are worth naming. State is a Map keyed by id, so a patch is a single set rather than an array scan — at a few thousand aircraft that difference is visible in the profiler. The requestId ref solves a race I hit in production: two reconnects in quick succession fire two snapshot fetches, and if the first resolves last it silently reinstates older data. And returning prev unchanged when a removal targets an id we do not hold avoids a pointless re-render of every subscriber.
Backpressure and batching
Real-time geospatial data arrives faster than the UI needs to render it. Updating React state on every message at 20Hz melts low-end machines. We batched socket events into a buffer flushed on requestAnimationFrame, and memoized map overlays so only changed aircraft re-rendered. The map stayed smooth while the wire stayed busy.
The batching hook, complete and reusable:
// hooks/useBatchedSocketEvent.ts
import { useEffect, useRef } from "react";
import { getSocket } from "@/lib/socket";
/**
* Collects socket events into a buffer and flushes them once per animation
* frame. The UI updates at most 60 times a second no matter how fast the
* wire is, and React batches the whole flush into one render.
*/
export function useBatchedSocketEvent<T>(
event: string,
onFlush: (batch: T[]) => void
): void {
const onFlushRef = useRef(onFlush);
useEffect(() => {
onFlushRef.current = onFlush;
}, [onFlush]);
useEffect(() => {
const socket = getSocket();
let buffer: T[] = [];
let frame: number | null = null;
const flush = () => {
frame = null;
if (buffer.length === 0) return;
const batch = buffer;
buffer = [];
onFlushRef.current(batch);
};
const listener = (payload: T) => {
buffer.push(payload);
if (frame === null) frame = requestAnimationFrame(flush);
};
socket.on(event, listener);
return () => {
socket.off(event, listener);
if (frame !== null) cancelAnimationFrame(frame);
buffer = [];
};
}, [event]);
}Used from the flight hook, the patch handler becomes a single state update per frame regardless of message rate:
useBatchedSocketEvent<Flight>("flight:update", (batch) => {
setFlights((prev) => {
const next = new Map(prev);
for (const flight of batch) {
next.set(flight.id, { ...next.get(flight.id), ...flight });
}
return next;
});
});One subtlety worth internalising: requestAnimationFrame does not fire in a background tab, so the buffer grows while the tab is hidden and flushes in one burst on return. For a dashboard that is fine — the visibility-change resync replaces that state wholesale anyway. For an append-only feed you would want a size cap on the buffer, because an hour in a background tab is otherwise an unbounded array.
Failure states are UI states
A real-time dashboard that silently stops being real-time is dangerous — users trust stale data. Connection state was rendered explicitly: a status indicator, a "last updated" timestamp, and a degraded-mode banner when reconnecting. Operations staff told us this single banner was the most trusted feature in the app.
Testing the unhappy paths
The bugs live in reconnection, not connection. We tested by killing the server mid-session, throttling the network, and sleeping the laptop — manually at first, then scripted. If your real-time feature has only been tested on a stable localhost connection, it has not been tested.
How do you test a Socket.IO integration?
The trick is that the singleton makes the socket mockable at exactly one seam. Mock @/lib/socket and you can drive every event by hand, including the ones that are painful to reproduce against a real server:
// hooks/__tests__/useFlightState.test.ts
import { renderHook, act, waitFor } from "@testing-library/react";
import { useFlightState } from "../useFlightState";
// A minimal event emitter standing in for the real socket.
const handlers = new Map<string, Set<(payload: unknown) => void>>();
const mockSocket = {
connected: true,
on(event: string, fn: (payload: unknown) => void) {
if (!handlers.has(event)) handlers.set(event, new Set());
handlers.get(event)!.add(fn);
},
off(event: string, fn: (payload: unknown) => void) {
handlers.get(event)?.delete(fn);
},
};
function emit(event: string, payload?: unknown) {
handlers.get(event)?.forEach((fn) => fn(payload));
}
jest.mock("@/lib/socket", () => ({
getSocket: () => mockSocket,
}));
beforeEach(() => {
handlers.clear();
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => [
{ id: "a1", callsign: "BA117", lat: 51.4, lon: -0.4, altitude: 0, status: "scheduled" },
],
}) as jest.Mock;
});
test("snapshot loads on mount and patches apply on top", async () => {
const { result } = renderHook(() => useFlightState());
await waitFor(() => expect(result.current.flights.size).toBe(1));
expect(result.current.flights.get("a1")?.status).toBe("scheduled");
act(() => {
emit("flight:update", { id: "a1", status: "airborne", altitude: 3000 });
});
expect(result.current.flights.get("a1")?.status).toBe("airborne");
expect(result.current.flights.get("a1")?.callsign).toBe("BA117"); // merged, not replaced
});
test("reconnect re-fetches the snapshot and discards drifted state", async () => {
const { result } = renderHook(() => useFlightState());
await waitFor(() => expect(result.current.flights.size).toBe(1));
act(() => {
emit("flight:update", { id: "ghost", callsign: "GHOST", status: "airborne" });
});
expect(result.current.flights.size).toBe(2);
// The server never knew about "ghost"; a reconnect snapshot must remove it.
act(() => {
emit("connect");
});
await waitFor(() => expect(result.current.flights.size).toBe(1));
expect(result.current.flights.has("ghost")).toBe(false);
});That second test is the one that matters. It encodes the entire premise of the snapshot-plus-patch model — the server is authoritative and a reconnect discards local drift — as an assertion that fails if someone later "optimises" the resync away. Alongside these I keep manual checks that automation handles poorly: kill the server process mid-session, throttle to slow 3G in DevTools, and close the laptop lid for ten minutes. All three produced bugs that unit tests never would have.
What went wrong: the reconnect storm we caused ourselves
The worst outage on that project was self-inflicted. A deploy restarted the WebSocket server, and roughly four hundred connected dashboards all attempted to reconnect within the same second. Each successful reconnect fired the snapshot fetch shown above, so the REST API took four hundred simultaneous requests for the full flight list. It fell over, which failed the snapshots, which left clients retrying — and the retry traffic kept the API down for about eleven minutes after the WebSocket server was healthy again.
The fix was two lines of jitter, added to the reconnect handler rather than the socket config:
const resyncWithJitter = useCallback(() => {
const delay = Math.random() * 3000; // spread the herd across 3 seconds
const timer = setTimeout(() => void resync(), delay);
return () => clearTimeout(timer);
}, [resync]);Socket.IO's built-in randomizationFactor jitters the *reconnect attempt*, which we had — but our snapshot fetch fired on the connect event, so once connections succeeded the fetches were once again synchronised. Jittering the retry does not jitter what happens after it succeeds.
What I would do differently is treat the snapshot endpoint as a capacity question from day one. We had load-tested the WebSocket layer for concurrent connections and never load-tested the REST endpoint for the thundering-herd case that every restart guarantees. The lesson generalises: in a real-time system, the interesting load is not steady state, it is the moment everyone reconnects at once.
Where this fits
Real-time UI is one of those areas where senior experience pays for itself quickly, because the failure modes are invisible in demos and expensive in production. The aviation project this article draws from is documented in the case studies, and the architecture principles behind it are in how I structure React components for teams of 10+.
Muzamal Ali — Senior Frontend Engineer & Team Lead
Senior Frontend Engineer with 5+ years building production React and Next.js applications. I've led teams of 3–9 developers across healthcare, aviation, AI, and SaaS platforms. Based in Pakistan, working async with European tech teams.
Working on something similar?
I help European tech teams ship better frontends.


