Designing Real-Time Data Flow: WebSockets, Hooks, and State

Design a robust real-time architecture that carries data from a WebSocket server through a service/hook layer into React state, handling reconnection, event ordering, duplicate events, connection status, and backpressure correctly.

Real-time features — live dashboards, chat, collaborative editors, price tickers — look simple in a demo and fall apart in production. The demo code opens a socket, calls setState on every message, and calls it done. Production code has to survive flaky Wi-Fi, a server that restarts mid-session, a browser tab that gets backgrounded for ten minutes, and a burst of 500 events landing in 200ms. This lesson builds a layered architecture — Server → WebSocket → Service/Hook → State → Components — that isolates each of those concerns so your components stay simple while the plumbing underneath is resilient.

Why a layered architecture matters

The temptation with WebSockets is to open the connection directly inside a component with useEffect and call setState from the onmessage handler. This works until you need the same socket in two components, until you need to reconnect with backoff, or until you need to test the logic without a real network. A layered architecture separates transport (the raw WebSocket), transformation and lifecycle management (a service or custom hook), and presentation (components that only read state and dispatch actions).

Each layer has one job. The transport layer knows nothing about React. The service/hook layer knows nothing about how the UI renders. The component layer knows nothing about sockets, timers, or reconnection logic. This separation is what lets you unit-test the reconnection algorithm without mounting a single component, and what lets you swap WebSockets for Server-Sent Events later without touching your components.

• Transport layer: owns the raw WebSocket, its lifecycle events, and nothing else. • Service/Hook layer: owns reconnection, backoff, ordering, dedup, and exposes a clean state shape. • State layer: a reducer or store holding connection status and the latest data. • Component layer: pure rendering based on state; never touches the socket directly.

The five hard problems

Every real-time feature eventually has to answer five questions, and answering them late — after the feature already shipped — is expensive. Answer them up front in the service layer instead.

• Reconnection: when the socket drops, how do you get back online without hammering the server? • Event ordering: can events arrive out of order, and does your UI break if they do? • Duplicate events: can the same event arrive twice (e.g. after a reconnect replays a buffer), and is your state update idempotent? • Connection status: does the UI clearly show connecting / open / reconnecting / offline, or does it silently show stale data? • Backpressure: what happens when events arrive faster than React can render them?

A resilient WebSocket hook

Exponential backoff (2^n, capped at 30s) avoids hammering a server that is restarting or overloaded. seenIds and lastSeq live in refs, not state, because updating them must not itself trigger a re-render. The cleanup function guards against setting state after unmount by checking cancelled.

type ConnectionStatus = 'connecting' | 'open' | 'reconnecting' | 'closed';

interface WSMessage<T = unknown> {
  seq: number;      // monotonically increasing sequence number from server
  id: string;        // unique event id, used for dedup
  type: string;
  payload: T;
}

interface UseWebSocketResult<T> {
  status: ConnectionStatus;
  lastMessage: WSMessage<T> | null;
  send: (data: unknown) => void;
}

function useWebSocket<T>(url: string): UseWebSocketResult<T> {
  const [status, setStatus] = useState<ConnectionStatus>('connecting');
  const [lastMessage, setLastMessage] = useState<WSMessage<T> | null>(null);

  const socketRef = useRef<WebSocket | null>(null);
  const seenIds = useRef<Set<string>>(new Set());
  const lastSeq = useRef<number>(-1);
  const retryCount = useRef(0);
  const retryTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

  useEffect(() => {
    let cancelled = false;

    function connect() {
      const socket = new WebSocket(url);
      socketRef.current = socket;
      setStatus(retryCount.current === 0 ? 'connecting' : 'reconnecting');

      socket.onopen = () => {
        if (cancelled) return;
        retryCount.current = 0;
        setSta
…

Code Examples

The reducer centralizes how raw messages become UI state. Because ORDER_EVENT writes by id into a Record, replaying the same order twice (a duplicate event) produces an identical object reference-equal update, so it is safe by construction — dedup at the hook level is a performance optimization, not the only line of defense.

orders: { ...state.orders, [action.order.id]: action.order }

Never let stale data look fresh. The banner is a pure function of status and staleSince, uses aria-live so screen readers announce transitions, and disappears entirely once the connection is healthy again.

When a server pushes hundreds of events per second (e.g. a live order book), calling setState on every single one queues hundreds of re-renders and the UI falls behind — the classic backpressure failure. Buffering incoming items in a ref and flushing once per animation frame caps re-renders to the display refresh rate regardless of message rate, and slicing the array bounds memory growth.

Common mistake: Calling setState synchronously inside the WebSocket onmessage handler for a high-frequency stream, causing dropped frames and an unresponsive UI under load.

interface LiveOrder { id: string; status: 'pending' | 'filled' | 'cancelled'; qty: number; }

interface RealtimeState {
  status: ConnectionStatus;
  orders: Record<string, LiveOrder>;
  staleSince: number | null; // ms timestamp when we last lost connection
}

type RealtimeAction =
  | { type: 'STATUS_CHANGED'; status: ConnectionStatus }
  | { type: 'ORDER_EVENT'; order: LiveOrder };

function realtimeReducer(state: RealtimeState, action: RealtimeAction): RealtimeState {
  switch (action.type) {
    case 'STATUS_CHANGED':
      return {
        ...state,
        status: action.status,
        staleSince: action.status === 'open' ? null : state.staleSince ?? Date.now(),
      };
    case 'ORDER_EVENT':
      // Idempotent merge: applying the same order twice is a no-op change.
      return {
        ...state,
        orders: { ...state.orders, [action.order.id]: action.order },
      };
    default:
      return state;
  }
}

function useLiveOrders(url: string) {
  const { status, lastMessage } = useWebSocket<LiveOrder>(url);
  const [state, dispatch] = useReducer(realtimeReducer, {
    status: 'connecting',
    orders: {},
    staleSince: null,
  });

  useEffect(() => {
    dispa
…
function ConnectionBanner({ status, staleSince }: { status: ConnectionStatus; staleSince: number | null }) {
  if (status === 'open') return null;

  const label =
    status === 'connecting' ? 'Connecting…' :
    status === 'reconnecting' ? 'Reconnecting…' :
    'Offline';

  const staleSeconds = staleSince ? Math.round((Date.now() - staleSince) / 1000) : 0;

  return (
    <div role="status" aria-live="polite" className={`banner banner--${status}`}>
      {label}
      {staleSeconds > 0 && <span> · data may be up to {staleSeconds}s stale</span>}
    </div>
  );
}
function useBatchedStream<T>(source: { subscribe: (cb: (item: T) => void) => () => void }) {
  const [items, setItems] = useState<T[]>([]);
  const bufferRef = useRef<T[]>([]);
  const frameRef = useRef<number | null>(null);

  useEffect(() => {
    function flush() {
      if (bufferRef.current.length > 0) {
        const batch = bufferRef.current;
        bufferRef.current = [];
        setItems((prev) => [...prev, ...batch].slice(-500)); // cap memory
      }
      frameRef.current = requestAnimationFrame(flush);
    }
    frameRef.current = requestAnimationFrame(flush);

    const unsubscribe = source.subscribe((item) => {
      bufferRef.current.push(item);
    });

    return () => {
      unsubscribe();
      if (frameRef.current) cancelAnimationFrame(frameRef.current);
    };
  }, [source]);

  return items;
}

Real-time data flow

Data flows one way down through layers; commands (subscribe, send) flow back up through the same layers.

┌──────────┐   messages    ┌───────────────┐
  │  Server  │ ────────────▶ │  WebSocket     │
  │ (source) │ ◀──────────── │  (transport)   │
  └──────────┘   acks/pings  └───────┬───────┘
                                     │ raw frames
                                     ▼
                        ┌────────────────────────┐
                        │  Service / Hook layer   │
                        │  - reconnect + backoff  │
                        │  - order & dedup events │
                        │  - queue + flush (RAF)  │
                        └───────────┬─────────────┘
                                    │ clean actions
                                    ▼
                        ┌────────────────────────┐
                        │        State            │
                        │  (reducer / store)      │
                        │  status + data + errors │
                        └───────────┬─────────────┘
                                    │ selectors / props
                                    ▼
                        ┌────────────────────────┐
                        │   React Components      │
                        │  (pure render, no I/O)  │
              
…

Important Rules

Never let a component touch the WebSocket directly — always go through a service or hook.

Treat every incoming message as possibly duplicated and possibly out of order; guard with an id set and a sequence number.

Reconnection must use capped exponential backoff, never a fixed short interval, or you risk a thundering herd against your server.

Always expose connection status to the UI and render it — silent staleness is worse than a visible "Offline" banner.

When event rate can spike, buffer and flush on a frame or timer instead of calling setState per event.

Where this shows up

Trading dashboards, multiplayer cursors in collaborative editors (Figma-style), live sports scores, and chat apps all share this exact shape: a service/hook layer absorbs the mess of the network so components can stay declarative. Teams that skip this layering usually end up rebuilding it mid-project once the first production outage exposes silent staleness or a reconnect storm.

useEffect(() => { const ws = new WebSocket(url); ws.onmessage = e => setData(JSON.parse(e.data)); }, []) const { status, lastMessage } = useLiveOrders(url); // service/hook owns the socket

Inlining the socket in a component means no reconnection, no ordering guarantees, and duplicated logic the moment a second component needs the same stream.

setTimeout(connect, 1000) on every close, forever setTimeout(connect, Math.min(1000 * 2 ** retryCount, 30000))

A fixed retry interval across thousands of clients reconnecting simultaneously after a server restart creates a thundering herd; exponential backoff with a cap spreads reconnection load out.

Why should reconnection use exponential backoff instead of a fixed retry interval? A. It uses less CPU on the client B. It avoids many clients reconnecting simultaneously and overwhelming the server (thundering herd) C. It is required by the WebSocket protocol specification D. It makes the connection status update faster

B. It avoids many clients reconnecting simultaneously and overwhelming the server (thundering herd) — A fixed short interval means every disconnected client retries in lockstep, which can overwhelm a recovering server. Exponential backoff spreads retries out over time.

In the layered architecture (Server → WebSocket → Service/Hook → State → Components), which layer should own reconnection and deduplication logic? A. The Server B. The Component layer C. The Service/Hook layer D. The State/reducer layer

C. The Service/Hook layer — The service/hook layer sits between raw transport and state, and is where lifecycle concerns like reconnection, ordering, and dedup belong — keeping both the transport and the components simple.

Why is buffering and flushing on requestAnimationFrame useful for high-frequency event streams? A. It guarantees zero data loss B. It reduces the number of re-renders to roughly the screen refresh rate instead of the message rate C. It automatically deduplicates events D. It replaces the need for a WebSocket entirely

B. It reduces the number of re-renders to roughly the screen refresh rate instead of the message rate — Calling setState per message under a high event rate causes far more re-renders than the screen can display; batching and flushing once per frame caps render work to what can actually be shown, addressing backpressure.

Extend useWebSocket so that on reconnect, it sends a { type: "resync", since: lastSeq } message to the server requesting only events newer than the last one it successfully applied, and clears seenIds only when the gap since disconnect exceeds a threshold (to bound memory). Hint: Send the resync message inside socket.onopen, using lastSeq.current. For the seenIds bound, track a disconnect timestamp and clear the set if reconnecting takes longer than, say, 5 minutes.

Try it yourself — there's no single right answer here; compare your result against this lesson's examples.