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.
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.