A monorepo (Turborepo + pnpm) showcasing framework-agnostic performance
engineering: one TypeScript data-plane core β @orderbook/core β sustains
a 60 FPS order book UI against continuous exchange-grade streams
(Binance @depth@100ms, synthetic bursts up to 1,000 events/s), and two
independent renderers β Nuxt 4 (Vue) and React 19 (Vite) β consume
the exact same engine with equivalent performance discipline.
Verification gates (all blocking in CI): lint, typecheck, unit tests, E2E functional tests, and performance tests proving INP < 200ms and zero main-thread tasks >= 50ms under a 100 events/s load β enforced for both the Nuxt and React clients β plus a nightly 30-minute soak with heap-leak assertions.
apps/
landing/ # static root app (Vite + vanilla TS) β serves /
βββ vercel.json # rewrites /react/* and /nuxt/* to the clients
nuxt-client/ # Nuxt 4 renderer β served under /nuxt
app/pages/index.vue # shallowRef boundary + windowed v-for
worker/ # 1-line worker shim β @orderbook/core/worker
tests/e2e/ # functional, performance (INP/longtask), soak
react-client/ # React 19 + Vite renderer β served under /react
src/useOrderbook.ts # useSyncExternalStore at rAF cadence
worker/ # 1-line worker shim β @orderbook/core/worker
tests/e2e/ # the same functional + performance gates
packages/
core/ # @orderbook/core β the framework-agnostic engine
src/
client.ts # OrderbookClient: worker + ring + rAF orchestrator
feed-connection.ts # WebSocket / synthetic transport management
ring-buffer.ts # power-of-two ring buffer (back-pressure boundary)
orderbook/depth-diff.ts # pure Binance U/u/pu diff protocol engine
orderbook/frames.ts # wire parsing + worker view builder
orderbook/imbalance.ts # order-book imbalance metrics
orderbook/synthetic.ts # deterministic Binance-wire feed for CI
worker/orderbook.worker.ts# Comlink data plane (transferable views)
types.ts # strict shared types (no Vue, no React)
tests/ # unit tests (run in Node, no browser needed)
eslint-config/ # shared ESLint flat config (typescript-eslint)
docs/screenshots/ # profiler evidence for reviewers
.github/workflows/ # ci.yml (4 blocking gates), soak.yml (nightly)
@orderbook/core has zero framework dependencies (only comlink). It
ships raw TypeScript source (the "internal package" pattern) and exports:
| Export | Purpose |
|---|---|
OrderbookClient |
owns the worker, ring buffer, rAF drain loop, row pools; replaces a cached snapshot at most once per animation frame |
FeedConnection |
live Binance WS management (reconnect, raw passthrough) or synthetic transport |
DepthDiffEngine |
pure diff-depth protocol engine (buffer β sync β chain-validate β merge β hysteresis prune) |
parseFrame / buildView |
wire-format parsing + worker-side view builder with imbalance |
RingBuffer |
512-slot newest-wins back-pressure boundary |
SyntheticFeed |
deterministic Binance-wire-format generator (CI runs the identical code path as live) |
@orderbook/core/worker |
side-effect entry: Comlink.expose(new OrderBookWorker()) |
Each app contributes only a one-line bundler shim so its bundler can resolve the worker URL:
// apps/*/worker/orderbook.worker.ts
import '@orderbook/core/worker'flowchart LR
subgraph MainThread["Main thread (render plane)"]
WS["FeedConnection<br/>btcusdt@depth@100ms"] -->|"onmessage: raw string"| RING["RingBuffer<string> (512)<br/>back-pressure boundary"]
RING -->|"batch drain once per frame"| RAF["rAF loop in @orderbook/core"]
RAF -->|"ingestAndDrain(raws, 600)"| RPC
subgraph Transfer["transfer (zero copy)"]
RPC["Comlink RPC"]
end
TRANS["Float64Array bids/asks<br/>[price, qty, total, dir] * rows"] --> POOL["pooled row objects<br/>(mutated in place, zero alloc)"]
POOL --> SNAP["snapshot replaced once per frame"]
SNAP --> VUE["Nuxt: shallowRef.value = snap"]
SNAP --> REACT["React: useSyncExternalStore"]
end
subgraph WorkerThread["Worker thread (data plane: @orderbook/core/worker)"]
PARSE["parseFrame<br/>snapshot vs diff discrimination"] --> DIFF["DepthDiffEngine<br/>U/u/pu chain + level maps"]
DIFF --> SORT["buildView<br/>sort + totals + imbalance<br/>+ flash flags + prune"]
SORT --> TRANS
end
RPC -.-> PARSE
Both UI frameworks receive the same per-frame contract β a cached snapshot object whose identity changes at most once per animation frame, containing pooled row arrays that are mutated in place (zero steady-state allocation):
- Nuxt writes it into a single
shallowRef(value replaced, never mutated β no Vue reactivity ever wraps the book). - React reads it through
useSyncExternalStore, which re-renders at display cadence, never at message cadence.
| Concern | Decision | Why |
|---|---|---|
| Ownership split | WebSocket transport on main thread; parse/diff/sort in worker | The abort path can literally ws.close(); raw frames are forwarded unparsed, so the main thread only pays a postMessage per message (~Β΅s) while all data work is off-thread. The synthetic feed emits the same raw strings, so CI exercises the identical code path. |
| IPC | Comlink | Type-safe RPC over postMessage; the drain result is Comlink.transfer-ed (zero copy). Callbacks are avoided entirely β status rides along with every drain response. |
| Back-pressure | 512-slot RingBuffer<string> (newest-wins) |
Decouples message arrival rate from render cadence: frames accumulate between animation frames and are batch-forwarded in one RPC. Overflow breaks the worker's U/u/pu chain, which self-heals via a snapshot resync. |
| Reactivity | One cached snapshot per frame; Vue shallowRef replace / React useSyncExternalStore |
No framework reactivity wraps the book. The single write happens exclusively in the rAF loop; pooled row objects mean steady-state rendering allocates nothing. The snapshot reference only changes when rendered content changed (β€1 notify per frame, zero on a settled book) β the identity contract useSyncExternalStore requires. |
| Display smoothing | Exponential glide toward drain targets (Ο = 150ms, frame-rate independent), values matched by price with a two-pointer merge over double-buffered pools (smoothingMs: 0 opts out) |
Raw drains step values discretely, which reads as jumping numbers; smoothing makes quantities, totals, depth bars and mid/imbalance animate continuously at rAF cadence while keeping the zero-alloc steady state. New levels seed at target, so no fake full-scale sweeps. |
| Flash effects | Worker-computed dir flag + side-scoped CSS animation, 5% threshold | Restarting dozens of CSS animations per frame is itself a long-task vector; only meaningful qty moves flash. |
| Memory bounds | Hysteretic level pruning (engine 12kβ8k/side) + flash-map windowing (β€600/side) + row pooling | A diff stream accumulates dead far-levels forever; measured 50β140ms long-task growth within ~90s before this fix, zero after. |
| Imbalance | Worker-computed from window cumulative totals | Bid/ask pressure is a data-plane metric (bidVol / (bidVol+askVol)); both UIs render the same gauge without touching level maps. |
| Teardown | OrderbookClient.stop() is deterministic and re-entrant |
Nothing survives unmount: socket, worker heap, rAF loop, ring buffer and pools are all released; StrictMode's startβstopβstart cycle is safe. |
| CI determinism | Synthetic feed emitting Binance wire format | E2E/perf/soak run offline and deterministically; live Binance is one selector away (?feed=live). |
- Buffer events arriving before the REST snapshot (worker fetches it).
- On snapshot: drop buffered events with
u <= lastUpdateId, sync level maps. - First applied event must bridge
lastUpdateId + 1(U <= lastUpdateId+1 <= u). - Every later event must satisfy
pu === previous u; qty0deletes a level. - Any violation β reset + fresh snapshot (live) or
stalledstatus (synthetic, which makes the client restart its generator session).
| Metric | Budget | Measured (Nuxt) | Measured with |
|---|---|---|---|
| INP (worst interaction) | < 200 ms | 56 ms | Event Timing API, durationThreshold: 16 (stricter than Chrome's 40ms default), worst entry during an 8s interaction burst against a 100 events/s feed |
| Main-thread task | no task >= 50 ms | 0 long tasks | PerformanceObserver('longtask') over the same window |
| DOM size | < 100 rows in DOM | ~40 rows for 1,200 levels | virtualization check in both e2e suites |
| Sustained load (90s @ 100 ev/s) | no degradation over time | 0 long tasks, flat 14MB heap | scripts/soak-profile.mjs |
The React client runs the identical gates (apps/react-client/tests/e2e/),
proving the core keeps both frameworks inside the same envelope.
- Browser (
apps/nuxt-client/tests/e2e/soak.spec.ts, nightly workflow): 30 minutes at 100 events/s, heap + DOM sampled every 15s viaperformance.memory; fails on monotonic growth, heap > 350MB, or DOM inflation. - Engine (
packages/core/tests/soak.test.ts): the same 30 minutes replayed in Node through the exact browser pipeline (raw frames β parse β diff β view), asserting level maps, pending queue and flash maps stay bounded.
pnpm run test:soak # browser heap soak (SOAK_DURATION_MS=1800000)
pnpm run test:soak:unit # engine soak in NodeRequires Node >= 22 and pnpm >= 10 (corepack enable picks up the version
from packageManager).
pnpm install
# everything at once (landing :5173, react :5174, nuxt :3001)
pnpm run dev
# or one at a time
pnpm run dev:landing # http://localhost:5173 (CTAs auto-target local ports)
pnpm run dev:react # http://localhost:5174/react/
pnpm run dev:nuxt # http://localhost:3001/nuxt/
# verification gates
pnpm run lint # ESLint across every package
pnpm run typecheck # tsc / vue-tsc across every package
pnpm run test:unit # Vitest (core engine, protocol, imbalance, soak-model)
pnpm run test:e2e # Playwright functional + performance gates (both clients)
pnpm run ci # all blocking gates locallyBoth clients boot the deterministic synthetic feed by default; append
?feed=live for Binance or ?rate=1000 to stress the pipeline.
pnpm run favicon # apps/*/public/favicon.ico β hand-rolled PNG encoder, no deps
pnpm run og # apps/landing/public/og.png β 1200:630 Open Graph image
pnpm run og:verify # pixel-probe the rendered og.png against the design paletteThe OG image is a telemetry-dashboard snapshot of the engine rendered from
scripts/og/dashboard.html (a self-contained, seeded page) through
chrome-headless-shell in screenshot mode β the same binary Playwright
installs, driven directly with zero npm dependencies. CHROME_PATH overrides
the executable; OG_SCALE=1 bakes a 1200Γ630 PNG instead of 2400Γ1260.
The workspace deploys as three Vercel projects from this one repository, each with its Root Directory set (Vercel detects pnpm workspaces and installs the full graph automatically):
| Path served | Project | Root Directory | Build output |
|---|---|---|---|
/ |
landing | apps/landing |
static (Vite β dist) |
/react/* |
react-client | apps/react-client |
static (Vite β dist, base: '/react/') |
/nuxt/* |
nuxt-client | apps/nuxt-client |
Nitro (Nuxt, baseURL: '/nuxt/') |
Each app commits its own vercel.json (framework, buildCommand,
outputDirectory, installCommand), so project creation needs no manual
build settings. Routing lives in apps/landing/vercel.json: the landing
project rewrites /react/* and /nuxt/* to the two client deployments,
so one domain serves everything:
GET / β apps/landing (static)
GET /react/* β rewrite β react-client project (base /react/)
GET /nuxt/* β rewrite β nuxt-client project (base /nuxt/)
Setup:
- Create the three projects (Import Git repository, set the Root
Directory from the table above β this is the setting that decides
where Vercel looks for
dist; if you see "No Output Directory named dist found", the Root Directory is wrong). Framework Preset auto-detects from eachvercel.json. - Copy each client's production domain into the literal rewrite
destinations committed in
vercel.json(repo-root project) andapps/landing/vercel.json(apps/landingproject) β whichever project serves/uses the matching file. Destinations are committed as full URLs instead of${ENV_VAR}references on purpose: Vercel does not error on an unset variable in a rewrite destination (the reference stays literal and the route 404s), so plain URLs remove that failure mode. - Redeploy the landing project so the new rewrites go live.
No path-prefixed asset config is needed beyond what is committed: Nuxt serves
/nuxt/* natively (app.baseURL is baked into the Nitro routes), while the
static React build serves dist/ at the project root β its own vercel.json
rewrites /react/* β /* so the deployment answers on the /react prefix the
landing rewrite forwards.
Capture evidence after running the soak locally and attach screenshots to
docs/screenshots/ β reviewers expect proof, not promises:
# 1. start a 100 events/s feed and profile it
pnpm run dev:nuxt &
npx playwright test --project=soak # or open http://localhost:3001/nuxt/?feed=synthetic&rate=100
# 2. Chrome DevTools β Memory tab β heap snapshot at t=0 and t=30min
# 3. Performance tab β record 30s; screenshot the main-thread flame chart
# 4. save as docs/screenshots/{heap-t0.png,heap-t30m.png,flamechart.png}| Evidence | Expected result |
|---|---|
docs/screenshots/heap-t0.png vs heap-t30m.png |
flat JS heap; no growing Map / pools |
docs/screenshots/flamechart.png |
continuous rAF ticks, no task crossing the 50ms line |
| Soak workflow artifacts | soak-timeline.json with heap/DOM samples across 30 minutes |
| CI performance test output | worst interaction ~56ms, zero long tasks (both clients) |