Notes/May 14, 2026 · 8 min

A ref-counted WebSocket layer for real-time market data

How to feed an order book that updates dozens of times per second without melting React: shared subscriptions, throttled cache patching, and reconnect that replays state.

WebSocketReactReal-timeArchitecture

An order book updates tens of times per second. A React component tree re-rendering at that rate is unusable — and the naive architecture, where each component opens its own socket subscription, is worse: mount the same symbol in a chart, a ticker, and an order form, and you're paying for three copies of the same stream.

Building the trading frontend for a crypto exchange, I ended up with a design I'd now reach for in any real-time UI: a centralized WebSocket layer with ref-counted subscriptions and throttled cache patching. No component ever touches a socket.

The interface: subscribe, and nothing else

The entire public surface is one function. Components declare what data they need; the layer decides what that means for the wire.

type Topic = `orderbook:${string}` | `trades:${string}` | `ticker:${string}`;

function subscribe(topic: Topic): () => void {
  const entry = registry.get(topic) ?? createEntry(topic);
  entry.refCount += 1;

  if (entry.refCount === 1) {
    // First consumer: actually subscribe upstream.
    socket.send({ op: "subscribe", topic });
  }

  return function unsubscribe() {
    entry.refCount -= 1;
    if (entry.refCount === 0) {
      socket.send({ op: "unsubscribe", topic });
      registry.delete(topic);
    }
  };
}

The ref-count is the whole trick. The first subscriber to a topic opens the upstream subscription; every later subscriber is free; the last unsubscribe tears it down. Components mount and unmount as violently as React wants — the wire only sees the transitions that matter.

Ticks patch a cache, not components

Incoming messages never call setState. They patch a normalized cache keyed by topic, and the cache notifies subscribers on a throttled cadence — 200–300ms worked well for market data. The order book renders at a fixed, predictable rate regardless of how fast ticks arrive.

socket.onMessage((msg) => {
  // Hot path: mutate the cache entry, don't render.
  applyPatch(cache.get(msg.topic), msg);
  scheduleFlush(msg.topic); // throttled, one flush per topic per window
});

function scheduleFlush(topic: Topic) {
  if (pending.has(topic)) return;
  pending.add(topic);
  setTimeout(() => {
    pending.delete(topic);
    notifySubscribers(topic); // now React renders — once
  }, FLUSH_MS);
}
  • Bursts collapse: 40 ticks in a window become one render.
  • Backpressure is explicit: FLUSH_MS is a product decision (how live should it feel?), not an accident of load.
  • The hot path allocates nothing and renders nothing — profiling stays boring, which is the goal.

Reconnect replays the ref-count table

The registry doubles as the source of truth for recovery. When the socket drops and reconnects (exponential backoff), the layer walks the registry and re-subscribes every topic with a non-zero ref-count. Components don't know the connection died; they just see the next cache flush.

A network blip should be an implementation detail. If a component has to handle reconnection, the abstraction is leaking.

What I'd keep, what I'd change

Keep: the single subscribe() surface, ref-counting, and the throttled cache — that trio removed an entire class of performance bugs. Change: I'd add sequence numbers per topic from day one, so the client can detect gaps after reconnect and request a snapshot instead of trusting replay. We added it later; it should have been in the first version.

I'm Mohsen— a full-stack engineer building real-time platforms and AI products. If you're working on something in this space, I'd love to hear about it.

Read next

Preventing double-bookings with transactional slot locking