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.

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.