Turborepo monorepo · pnpm · TypeScript
High-Frequency Orderbook Render Engine
A framework-agnostic data-plane core — @orderbook/core — that keeps
60 FPS against sustained exchange-grade streams, with two independent renderers
(Nuxt and React) consuming the exact same engine.
- INP < 200ms
- 0 long tasks ≥ 50ms
- ~40 DOM rows for 1,200 levels
- zero steady-state allocations
The Problem
A raw exchange feed at 100–1,000 events per second is a
denial-of-service attack on the browser main thread. Naive designs parse, diff,
sort and patch reactive state per message:
- JSON parsing + depth diffing runs on the UI thread → long tasks
- deep reactivity walks the whole book every update → GC churn
- message rate dictates render rate → layout thrash, frozen input
- the DOM grows with the book → memory leaks within minutes
Result: the tape renders, the page stops responding.
The Solution
Move the entire data plane into a Web Worker behind a
framework-agnostic core, and decouple message rate from render cadence:
- parse → sequence-validate → merge → sort all happen off-thread
- a 512-slot ring buffer is the back-pressure boundary
- one rAF drain per frame batches the whole backlog into one RPC
- windowed views are packed into transferable Float64Arrays (zero copy)
- pooled rows + hysteretic pruning keep heap flat over 30-minute soaks
Result: 1000Hz in, one cheap snapshot per frame out — identical for every UI framework.
Architecture: one core, two renderers
exchange feed (live WS / synthetic 1000Hz)
│ raw JSON strings
▼
┌─────────────────────────────────────────────────────────────┐
│ @orderbook/core ← framework-agnostic, no Vue/ │
│ ┌───────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ FeedConnection│──▶│ RingBuffer(512)│──▶│ rAF drain loop │ │
│ │ WS management │ │ back-pressure │ │ one batch/frame│ │
│ └───────────────┘ └────────────────┘ └───────┬────────┘ │
│ │ Comlink │
│ ┌────────────────────────────────────────────────▼────────┐ │
│ │ WORKER: parse → U/u/pu diff engine → sort → imbalance │ │
│ │ → windowed Float64Array views (TRANSFERRED) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ pooled rows → snapshot replaced once per frame │
└───────────────┬───────────────────────────┬─────────────────┘
▼ shallowRef replace ▼ useSyncExternalStore
apps/nuxt-client (Vue 3) apps/react-client (React 19)
Key architectural decisions
1 · The core owns every high-frequency mechanism
Apps never touch the feed, the ring buffer or the worker. They construct one
client and consume per-frame snapshots — so Vue and React share the exact same
performance envelope.
// any app — the entire integration surface
const client = new OrderbookClient({
symbol: 'btcusdt',
mode: 'synthetic', // or 'live' for Binance
workerFactory: () => new Worker(
new URL('./worker/orderbook.worker.ts', import.meta.url),
{ type: 'module' }), // 1-line shim → @orderbook/core/worker
})
client.subscribe(render) // called once per animation frame
client.start()
2 · Nuxt: one shallowRef, replaced in rAF only
Book data never enters deep reactivity. The single write happens in the
client's rAF-aligned listener; rows are pooled and mutated in place, so
steady-state rendering allocates nothing.
// apps/nuxt-client — the only reactive boundary
const snapshot = shallowRef<BookSnapshot>(EMPTY)
client.subscribe(() => {
snapshot.value = client.getSnapshot() // replace, never mutate
})
3 · React: useSyncExternalStore at display cadence
The core's cached snapshot changes identity at most once per frame, which is
exactly the contract useSyncExternalStore needs — React re-renders
at rAF cadence, never at message cadence.
// apps/react-client — equivalent discipline, idiomatic React
const subscribe = (cb) => client.subscribe(cb)
const getSnap = () => client.getSnapshot()
const snapshot = useSyncExternalStore(subscribe, getSnap)
4 · Zero-copy IPC from worker to DOM
The worker packs the windowed book into flat
[price, qty, cumulative, dir] rows and transfers the
buffers — no structured clone, no per-row objects crossing the boundary.
// packages/core — worker drain result (transferred, zero copy)
return Comlink.transfer(
{ bids, asks, mid, spread, imbalance, status, seq },
[bids.buffer, asks.buffer], // ownership moves to the main thread
)
See it under load
Both demos boot a deterministic synthetic feed by default (identical wire format
to Binance) — switch to the live exchange from the Feed selector inside either app.
Push the rate to 1000 events/s; the tape keeps rendering at 60 FPS.