Back to Home
Completed
1 Month

Realtime Market Lab.

A simulator-driven performance lab dressed as a market dashboard. A fake exchange generates 500–5,000+ ticks/sec so you can prove which React techniques hold 60 FPS — and which collapse.

FrontendNext.js 16
StateZustand
TransportWebSocket
ComputeWeb Worker
RenderVirtualized
BackendFastAPI

Overview

This is a simulator project, not a trading product. A Python MarketSimulator fakes an entire exchange so the frontend is always the bottleneck under test. Push the Simulation Workload sliders to 5,000–10,000 instruments at 5,000+ updates/sec and compare rendering strategies side-by-side.

Key focus areas:

  • Simulator heartbeat that scales to 10k symbols without choking
  • Server coalescing + 50ms batched frames
  • rAF-aligned drains + 1 commit per flush
  • Selective subscriptions + virtualization + Worker offload

Simulator — How Fake Counts Are Generated

backend/app/simulator.py uses one heartbeat loop, not one task per instrument:

  1. Wake every 20ms (TICK_SECONDS = 0.02), add updates_per_sec * 0.02 to fractional credit (_owed)
  2. Convert credit to integer n_events owed this tick — O(events/tick), not O(instruments)
  3. Sample random.choice(instruments) that many times, group by burst_size to fake bursty fills
  4. Mutate via _mutate(inst): price jitter ±volatility (default 0.06%), update high/low/volume, emit by mix — quote 70% / trade 20% / book 10%
  5. put_nowait to bounded asyncio.Queue; on QueueFull drop, never block heartbeat — producer-side backpressure

Instruments are synthetic (TECH00001 …): random base $5–$2,000, spread 1–8 bps. Quotes carry bid/ask + change%, trades carry qty/side, books carry 5 bids + 5 asks. Same normalized schema as the live Binance adapter, so frontend code never changes.

Impact & Results

10k instrumentsWorkload

Simulated market with configurable update rate up to 5k+/sec.

10x fewerCommits Cut

rAF + selective apply drops React commits while messages/sec stays identical.

60 FPSFPS

Batched + virtualized path holds frame rate where naive collapses.


Features

Rendering Scenarios

naive vs batched vs rAF + selective vs rAF + Worker offload — same workload, measurable FPS gap.

Server Batching

FastAPI ws_manager coalesces quotes per symbol (last-value-wins), flushes one JSON frame every 50ms with orjson.

Selective Store

Three Zustand stores (market / config / metrics) + per-symbol selectors so rows only re-render on their own tick.

Worker Ranking

Top-movers computed in stats.worker.ts off the main thread, posted from drained events.


Technical Challenges

Thousands of Ticks Per Second

The Problem

Naive onFrame apply triggers a React commit per message — FPS collapses and dropped frames climb.

The Solution

Server 50ms coalesced flush + client drain (setInterval 100ms or rAF) with ONE set() per drain patching only touched symbols.

Result: Messages/sec identical, React commits/sec drops 10x, FPS recovers to 60.

DOM Bloat at 10k Rows

The Problem

Rendering 10k rows creates 10k DOM nodes regardless of viewport.

The Solution

TanStack Virtual in Watchlist and TradeFeed + capped trade feed (300) + capped client buffer (50k, drop-oldest).

Result: DOM node count independent of dataset size, smooth scroll under load.

Main-Thread Ranking

The Problem

Sorting 10k instruments for top gainers/losers on every tick blocks paint.

The Solution

Moved ranking to Web Worker; PriceChart subscribes via store.subscribe outside React entirely.

Result: No commit cost for charts and rankings during high-frequency ticks.

Event Flow

Simulator Generates Load

Single heartbeat loop (20ms) computes owed events from configured rate — O(events/tick), not O(instruments).

Server Coalesces + Flushes

ClientConnection keeps latest quote/book per symbol, appends trades, flushes every 50ms as binary orjson frame.

Client Buffers

MarketWebSocketClient decodes, records latency sample, pushes to array buffer with hard 50k cap (drop-oldest).

Scenario Drains + Applies

naive / batched / rAF / worker-offload drains buffer, calls marketStore.applyEvents once, fans out via selective subscriptions.


Performance Techniques Used

OptimizationResult
Server coalesce: quotes/books last-value-wins, trades capped 500/flush20 frames/sec
Server flush: 50ms orjson binary frame, not N JSON frames50ms batch
Client drain: naive vs 100ms batch vs rAF vs rAF+Worker1 set() per drain
Store patch: immutable patch only touched symbolsselective re-render
DOM: TanStack Virtual + trade feed capped 300 + buffer capped 50k drop-oldestviewport-bound
Off-thread: stats.worker.ts ranking + canvas chart via store.subscribe0 React commits

Performance Optimizations

OptimizationResult
Server flush50ms coalesced
Client drainrAF-aligned
State commits1 per drain
Trade feed cap300
Client buffer cap50k drop-oldest
MetricsFPS / commits / latency / memory

Lessons Learned

  • Batching: Server coalescing + client rAF drain beats per-message setState by an order of magnitude.
  • Isolation: Separate market / config / metrics stores prevents perf-panel re-renders from polluting measurements.
  • Virtualization: DOM cost must be viewport-bound, not data-bound.
  • Workers: Even fast sorts hurt at 5k/sec — move them off-thread.

Try It

frontend/
├── app/                   Next.js App Router entry
├── components/            Dashboard panels
├── hooks/                 useMarketConnection, usePerfMetrics
├── lib/                   marketStore, configStore, metricsStore, wsClient
└── workers/
    └── stats.worker.ts    Top-movers ranking off main thread

Liked this project?

Check out more of my work or get in touch.