← Back to blog

WebSocket State Management Patterns for Crypto Trading Apps

A comparison of architectures for handling persistent WebSocket connections in React - reconnection logic, subscription lifecycles, and graceful degradation.

By Oliver Benns


Every trading application lives and dies by its WebSocket connection. Market data, order updates, and account changes all arrive over persistent connections that need to stay alive, recover from failures, and distribute data to the right components without causing render storms.

This post covers the patterns that work in production - and the ones that fall apart under pressure.

The naive approach and why it breaks

The most common first attempt is a useEffect that opens a WebSocket and writes messages to state:

useEffect(() => { const ws = new WebSocket(url); ws.onmessage = (e) => setData(JSON.parse(e.data)); return () => ws.close(); }, [url]);

This works in a demo. In production, it fails in several ways:

  • Every message triggers a re-render
  • Multiple components opening separate connections to the same feed
  • No reconnection logic
  • No way to share the connection across the component tree

Pattern 1: Singleton connection manager

Extract WebSocket management into a module-level singleton that lives outside React entirely.

class ConnectionManager { private ws: WebSocket | null = null; private subscribers = new Map<string, Set<(data: unknown) => void>>(); private reconnectTimer: ReturnType<typeof setTimeout> | null = null; connect(url: string) { this.ws = new WebSocket(url); this.ws.onmessage = (event) => { const message = JSON.parse(event.data); const channel = message.channel; this.subscribers.get(channel)?.forEach((cb) => cb(message.data)); }; this.ws.onclose = () => { this.reconnectTimer = setTimeout(() => this.connect(url), 1000); }; } subscribe(channel: string, callback: (data: unknown) => void) { if (!this.subscribers.has(channel)) { this.subscribers.set(channel, new Set()); } this.subscribers.get(channel)!.add(callback); return () => { this.subscribers.get(channel)?.delete(callback); }; } } export const connectionManager = new ConnectionManager();

Components subscribe to specific channels. The connection is opened once and shared.

Pattern 2: Zustand middleware

If you are already using Zustand, a WebSocket middleware integrates cleanly with your store:

import { create } from "zustand"; interface PriceStore { quotes: Record<string, Quote>; connect: (url: string) => void; } export const usePriceStore = create<PriceStore>((set) => ({ quotes: {}, connect: (url) => { const ws = new WebSocket(url); ws.onmessage = (event) => { const quote: Quote = JSON.parse(event.data); set((state) => ({ quotes: { ...state.quotes, [quote.symbol]: quote }, })); }; }, }));

Components select only the data they need, so a BTC price update does not re-render a component displaying ETH.

const btcPrice = usePriceStore((state) => state.quotes["BTC"]?.price);

Pattern 3: Ref-based buffer with RAF flush

For the highest throughput scenarios, store incoming data in a mutable ref and flush to state on requestAnimationFrame:

function useBufferedWebSocket(url: string) { const buffer = useRef<Map<string, Quote>>(new Map()); const [quotes, setQuotes] = useState<Map<string, Quote>>(new Map()); useEffect(() => { const ws = new WebSocket(url); ws.onmessage = (event) => { const quote: Quote = JSON.parse(event.data); buffer.current.set(quote.symbol, quote); }; let rafId: number; const flush = () => { if (buffer.current.size > 0) { setQuotes((prev) => new Map([...prev, ...buffer.current])); buffer.current.clear(); } rafId = requestAnimationFrame(flush); }; rafId = requestAnimationFrame(flush); return () => { ws.close(); cancelAnimationFrame(rafId); }; }, [url]); return quotes; }

This naturally caps updates to the display refresh rate and collapses redundant updates to the same symbol.

Reconnection strategies

A production WebSocket connection needs:

  • Exponential backoff - 1s, 2s, 4s, 8s, capped at 30s
  • Heartbeat monitoring - send pings at regular intervals, reconnect if no pong arrives
  • State reconciliation - after reconnecting, request a fresh snapshot before processing deltas
  • Connection state exposure - components should know if the feed is live, reconnecting, or disconnected
type ConnectionState = "connecting" | "connected" | "reconnecting" | "disconnected";

Expose this state to the UI so traders see a clear indicator when data may be stale.

Subscription lifecycle management

Components mount and unmount. Each should subscribe on mount and unsubscribe on cleanup. The connection manager should track active subscriptions and only maintain channels that have at least one subscriber.

useEffect(() => { const unsubscribe = connectionManager.subscribe("orderbook:BTC", (data) => { setOrderBook(data as OrderBook); }); return unsubscribe; }, []);

If no component is listening to a channel, send an unsubscribe message to the server to reduce bandwidth.

Handling stale data

When a WebSocket reconnects after a gap, any data displayed from before the disconnection is potentially stale. Two approaches:

  1. Timestamp validation - compare the latest message timestamp against the current time. If the gap exceeds a threshold, mark the data as stale in the UI.
  2. Sequence numbers - track sequence IDs from the exchange. If a gap is detected, discard local state and request a fresh snapshot.

Choosing a pattern

ScenarioRecommended pattern
Single data source, few subscribersSingleton manager
Multiple stores, selective re-renderingZustand middleware
Very high message rate (>100/s)Ref buffer with RAF flush
Complex subscription logicSingleton + custom hooks

In practice, many trading applications combine these - a singleton manager for the connection itself, with Zustand or a ref buffer for the state layer. The key principle is the same across all patterns: keep the WebSocket lifecycle outside React, and control how and when data enters the render cycle.

Kickstart Your Trading Application

Hedge UI is a React starter kit with production-ready trading components, real-time data handling, and customisable layouts — so you can ship faster.

Get Hedge UI