A small real-time simulator for task routing and load balancing: you pick a strategy, tweak load, and watch queues, throughput, and utilisation change as tasks arrive and workers drain.
I wanted a concrete place to think about system design trade-offs—not slides, not a single algorithm in isolation. Routing policies behave differently under bursty arrivals and uneven work; seeing that in a live loop (and stepping through it in replay) made the trade-offs stick for me. This repo is that playground, wired up end to end so the UI, API, and simulation stay honest with each other.
- Live simulation over WebSockets: snapshots and events stream to the dashboard as the clock ticks.
- Pluggable strategies: round robin, priority (queue ordered by priority, assign to least-loaded worker), and random.
- Metrics: smoothed throughput, average wait in ticks, queue depth, pool and per-worker utilisation.
- Replay: append-only event log with play, pause, step forward/back, seek, and jump back to live.
- Twin comparison (optional): a second engine sees the same arrivals each tick so you can compare two strategies without a biased workload.
- Session export/import: JSON tape + runtime config; useful for sharing a run or debugging.
- Seeded randomness: same seed gives the same stochastic arrivals and random routing decisions (see
SIM_RNG_SEEDin.env.example).
Browser (React + Vite)
│ WebSocket JSON /ws
▼
Express + ws ──► SimulationSession ──► fan-out to connected clients
│ │
│ ├── SimulationEngine (primary)
│ ├── optional silent twin engine (A/B)
│ └── EventStore (ring buffer, replay index)
▼
REST /api/simulation/* state, metrics, config, clock, session, seed
The engine advances in ticks: generate arrivals, drain worker load, assign tasks from the queue according to the active strategy, emit structured events, then roll metrics from recent history. The frontend is mostly a consumer of that stream plus controls for speed, scenario, and strategy.
WebSockets instead of polling — The simulation already emits many small updates per tick. Pushing events keeps latency low and avoids the client guessing when to refresh; REST stays for things that are naturally request/response (export, import, one-off config).
Rolling / windowed metrics — Throughput and latency are derived from recent ticks, not lifetime totals. That matches how you’d eyeball a live system: “what’s happening now,” not a number dominated by minute one.
Replay on the same event log — Decisions and tick summaries are recorded once. Replay re-emits them so you can inspect a run without re-running the engine or fighting desync between “live” and “history” state.
Shared TypeScript package (@sds/shared) — Client and server use the same message and event types so the wire format doesn’t drift silently when you change one side.
cd system-decision-simulator
npm install
npm run dev- Frontend: usually
http://localhost:5173(Vite proxies/apiand/wsto the backend). - Backend: default
http://localhost:4000(override withPORTin.env).
Other useful commands:
npm run dev -w backend # API + WS only
npm run test -w backend # Vitest (strategies, determinism, HTTP/WS smoke)
npm run buildCopy .env.example to .env if you want to tune PORT, SIM_BASE_TICK_MS, SIM_MAX_EVENTS, SIM_RNG_SEED, or LOG_LEVEL.
Node.js, Express, ws, React, Vite, TypeScript, npm workspaces (shared, backend, frontend). Logs via pino.
- Richer comparison mode — e.g. clearer diff of decisions side by side, or exporting comparison runs as a first-class artifact.
- Persistence — today export/import is manual JSON; a small DB or file-backed session store would help if this grew beyond a demo.
- Visualisation — more signal on per-strategy fairness and tail latency (even rough) without cluttering the main view.
MIT License