Performance & architecture audit — Q4 2026
Prioritized gap analysis for turning AXIS into a production-grade, super-snappy, modern charting application.
This page
1. Baseline health
The repo is already in a strong performance culture:
- Type check passes (
bun run typecheckclean). - Unit + worker test suite passes: 4708 pass / 10 skip / 0 fail.
lintfinishes with zero errors (warnings only).- A dedicated firehose bench exists under
AXIS_BENCH=1(tests/bench/). - The v1 live chart path has explicit frame-budget guardrails (
e2e/v1-chart.spec.ts, 50 mspanFrameceiling). CHANGELOG.mdshows recent O(n) fixes (pre-eval call scanning, bar append path no longer re-sanitizes clean bars, windowed merges are linear).
The objective now is to move from "already good" to "absolutely world-class": sub-16 ms feedback, 60 FPS during live streaming + pan/zoom, and a UI/UX that feels like a premium commercial product.
2. Architecture strengths to preserve
- Plugin boundary is respected.
AXIS ≠ engineis enforced throughsrc/plugins/*,src/engines/*,src/workers/*. No Solid component imports PYNE directly. - Live/paint split is explicit.
src/chart/live-append.tsowns the only chart paint path from a live tick, with coalesced flushes and ranging-awaresetDatasuppression. - Data Source Manager is backgrounded.
src/data/data-source-manager.tsdoes all network paging on detached async jobs;PAGE_YIELD_MSkeeps a page from starving the event loop. - Per-slot runtime is separated from global state.
src/chart/chart-registry.tskeeps per-slot bars/gen in a dedicated store while managers/layers are imperative refs. - Lazy UI loading.
src/app.tsxsplits heavy shells (StudioHost,EditorPane,CommandPalette, panels) withlazy().
3. Top bottlenecks & weaknesses
3.1 Reactivity surface too coarse
- Evidence:
src/store/index.tsis 1,300+ lines and exports many mutation helpers.loadBars(src/store/index.ts:3252) usesbatch()but still replacesstore.barswith a fresh array, which notifies any component/effect that readsstore.bars. - Impact:
App.tsxupdatesdocument.titleon every bar update (barseffect,src/app.tsx:179), andChartHost.tsxhas ~15createEffects that read global store paths. A live tick causes a chain of effect runs even when nothing visible changes. - Severity: High.
3.2 ChartHost is an effect-dense component
- Evidence:
src/chart/ChartHost.tsxcontains 11+createEffects withuntrack()fences. Several effects readstore.chartDataGen/store.bars.lengthper live append to guard overlays/drawings/pins. Each effect re-evaluates independently. - Impact: More effect scheduling means more microtasks, especially during streaming pan/zoom.
- Severity: High.
3.3 Overlay updates are unconditional
- Evidence: Compare overlay effect (
src/chart/ChartHost.tsx:529) callsapplyCompareOverlay()whenever tracked compare state changes, even if the visible OHLC suffix is identical. On-chain overlay effect (src/chart/ChartHost.tsx:573) maps a full reactive array of points on everychartDataGenbump. - Impact: Redundant full-series writes to LWC, GC pressure, possible frame drops.
- Severity: Medium-High.
3.4 Live tick still propagates to non-visual store paths
- Evidence:
appendBar(src/store/index.ts:3564) setsstore.live.needsRerunandstore.live.lastBarTimeon every accepted tick. These paths are read by the live re-run scheduler and, transitively, by indicator state. - Impact: Thrashing indicator state / re-run bookkeeping on every tick while eval is still running, causing unnecessary work.
- Severity: Medium.
3.5 Engine response parsing is main-thread synchronous
- Evidence:
src/engines/pro.ts,src/workers/pyodide-client.ts, andsrc/engine/*pass JSON back to the main thread and parse withJSON.parse. Large strategy/equity arrays block the main thread. - Impact: A large backtest or deep indicator run can freeze the chart for 50–300 ms while the result is deserialized and applied.
- Severity: High.
3.6 Memory pressure from long-lived transient objects
- Evidence:
validateJobBars(src/data/data-source-manager.ts:195-229) still callsrepairBarson the dataset when sorting is suspected. Array slices inapplyCompareOverlay,applyOnchainOverlaysandseries-factory.tsallocate new objects on every effect run. - Impact: Extra GC on long sessions / 50k+ datasets.
- Severity: Medium.
3.7 UI theming and layout effects are not batched
- Evidence:
src/ui/panels/DockColumn.tsxreadsstore.panelChromein three separate memos (ids,width,bottomHeight) plusgetHoverSlideExpandedMap().src/chart/ChartHost.tsx:448schedules a reflow effect onstore.panelChromeandstore.chartLayout.mode. - Impact: A single panel resize may trigger multiple effect passes and reflow RAFs.
- Severity: Medium.
3.8 Accessibility & polish gaps
- Evidence: Micro-interactions rely on ad-hoc inline Tailwind classes (e.g.,
src/ui/shortcuts/Feedback.tsxuses a hard-coded 1200 ms timeout and noprefers-reduced-motion). Many modal/panel components do not exposearia-labelledbyroutes. Focus traps are implemented per-component rather than through a single focus manager. - Impact: Power users notice inconsistent feedback; keyboard/screen-reader users hit friction.
- Severity: Medium.
4. Prioritized improvement plan
Phase 0 — Measure first (week 1)
- Add runtime telemetry hooks:
performance.markaroundappendBar,setDataToChart, engine request/response, and JSON parse.- Track effect-count per tick using a dev-only ProbeStore wrapper.
- Record
panFrame/rAFbudgets from the existing e2e harness.
- Create a profiling issue template and baseline dashboard entry.
- Establish decision rule: no Phase 1 change ships without a before/after measurement.
Phase 1 — Reactive architecture (weeks 2–3)
- Decouple bars from the giant app store.
- Move the canonical OHLCV series and live-tip metadata out of
store.barsinto a dedicated, narrow signal/store (src/stores/bars.ts). - Keep
AppStateas UI orchestration state only; derive any UI reads from the narrow store.
- Move the canonical OHLCV series and live-tip metadata out of
- Audit every
store.barsreader.- Replace
createEffect(() => { const n = store.bars.length; ... })with fine-grained derived signals (tip,lastPrice,dataGen). - Throttle
document.titleupdates to 250 ms or only on tip closure.
- Replace
- Coalesce ChartHost effects.
- Merge overlay and drawing effects where possible; use a single
createEffectwith a discriminated notification object. - Guard every chart write behind a deep-equality check for the visible data window.
- Merge overlay and drawing effects where possible; use a single
- Batch store mutations.
- Wrap every public mutation helper in
batch()where not already done. - Eliminate dual writes to legacy flat flags +
panelChromein the same turn.
- Wrap every public mutation helper in
Phase 2 — Rendering & data-plane performance (weeks 3–4)
- Incremental series updates.
- Make
applyCompareOverlay,applyOnchainOverlays, and indicator apply paths compute a diff and only callseries.update/setMarkersfor changed data. - Use typed arrays or shared flat buffers for large numeric series when calling into LWC.
- Make
- Ranging-aware overlay writes.
- Suppress overlay/marker full-rewrites while
isRanging()is true, mirroring the candle slide logic.
- Suppress overlay/marker full-rewrites while
- Lazy bar payloads.
- Reuse arrays between store and slot cache by slicing from the same immutable ring buffer; avoid
unwrap()copies on each append.
- Reuse arrays between store and slot cache by slicing from the same immutable ring buffer; avoid
- GC hygiene.
- Pool transient arrays in heavy paths (indicator apply preview, on-chain overlays) with size caps.
Phase 3 — Engine boundary improvements (weeks 4–5)
- Main-thread deser offloading.
- For large responses, parse engine JSON in a Worker (
src/workers/json-parse.worker.ts) or use streamingJSON.parse(asyncchunked parser) for result arrays > 100 KB.
- For large responses, parse engine JSON in a Worker (
- Typed-result contracts.
- Add engine result envelopes that separate metadata from numeric series, enabling transfer of typed arrays and reducing string overhead.
- Pyodide streaming.
- Evaluate whether
TransformStreamcan push back partial line/mark results as they are produced, instead of one huge JSON string.
- Evaluate whether
- Result caching.
- Cache indicator evaluation for unchanged
(scriptId, barsGen, inputs); invalidate only when one of those changes.
- Cache indicator evaluation for unchanged
Phase 4 — UX / visual polish (weeks 5–6)
- Design system hardening.
- Ensure every color/spacing/radius uses the void Tailwind theme tokens in
src/index.css(@theme); delete leftover hard-coded color literals. - Add
prefers-reduced-motionsupport globally; remove layout-property CSS transitions and rely ontransform/opacityonly.
- Ensure every color/spacing/radius uses the void Tailwind theme tokens in
- Focus & keyboard system.
- Introduce a central focus manager for modals/panels/drawers (
src/ui/focus-trap.ts) and use it fromModal.tsx,StudioHost,AlertsPanel. - Add visible focus rings that match the design tokens; audit focus order in the chart/workspace.
- Introduce a central focus manager for modals/panels/drawers (
- Loading and feedback states.
- Unify skeletons/spinners under one component hierarchy.
- Improve shortcut feedback to respect reduced motion and debounce rapid chords.
- Mobile / responsive refinements.
- Verify bottom-sheet panel portals honor safe areas; reduce touch-target fragility on the toolbar.
Phase 5 — Continuous quality gates (week 6+)
- Add a
bun run perf:auditscript that runs unit/e2e + records selected benchmark numbers. - Add a CI gate that fails if
AXIS_BENCH=1firehose exceeds a budget. - Document ADR for the new narrow bars store and result-streaming contracts.
- Keep
CHANGELOG.mdupdated per checkpoint rule.
5. Validation metrics
| Metric | Current target | World-class target |
|---|---|---|
appendBar → paint latency (p99) | < 8 ms per tick | < 2 ms |
| Live pan/zoom frame budget (e2e) | < 50 ms | < 16 ms |
| Engine result JSON parse blocking | < 100 ms | parse off main thread or < 8 ms |
| Reactors triggered per live tick | untracked | ≤ 3 |
| Lighthouse performance | not measured | > 90 |
| Unit test pass rate | 100% | 100% with perf gates |
6. Risks & mitigations
| Risk | Mitigation |
|---|---|
| Splitting the store breaks persistence shape | Keep store.bars as a derived view for migration; persist canonical data through the narrow store only if needed |
| Off-thread JSON parsing increases latency for small results | Only offload when payload > threshold (50–100 KB) |
| Typed-array result contracts require backend changes | Gate behind feature flags; server-only first, Worker later |
| Too many UI polish changes regress accessibility | Add a11y lint / Playwright a11y spec |
7. Related files
- Store:
src/store/index.ts,src/chart/chart-registry.ts - Rendering:
src/chart/ChartHost.tsx,src/chart/live-append.ts,src/chart/series-factory.ts,src/chart/manager-access.ts - Data plane:
src/data/data-source-manager.ts,src/data/merge-datasets.ts,src/data/dataset-validate.ts - Engine boundary:
src/engines/pro.ts,src/engine/router.ts,src/workers/pyodide-client.ts,src/workers/pyodide-protocol.ts - UI shell:
src/app.tsx,src/ui/panels/DockColumn.tsx,src/ui/shortcuts/Feedback.tsx,src/index.css - Editor:
src/editor/PyneEditor.tsx - Tests/bench:
tests/bench/,e2e/v1-chart.spec.ts
8. Phase 0 / Phase 1 implementation notes
Updated during the first pass — these items have landed on the
developmentbranch.
Phase 0 — Instrumentation (landed)
- Added a tiny telemetry module at
src/perf/telemetry.tsthat wrapsperformance.mark/measureand only activates in dev/test builds. - Placed marks/measures on the current dominant costs so future work has a before/after signal:
src/store/index.ts:perfMark('axis:loadBars:setStore'),perfMark('axis:appendBar:store').src/chart/manager-access.ts:perfMeasureSync('axis:setDataToChart', …)around the full paint path.src/engines/json-sanitize.ts:perfMeasureSync('axis:parseEngineJson', …)around the shared engine response parser.
- Deviations from the plan: the dev-only ProbeStore wrapper and effect-count probe were deferred. The existing
chart.lag/chart.barsMCP app capabilities already expose live latency data, so we are using those plus the new marks for initial profiling.
Phase 1 — Narrow bar plane (landed)
- Created
src/stores/bars.tswith four fine-grained accessors:bars— full visible window (readers can still subscribe to the whole array).tip— the forming/newest bar; updates independently ofbarson every live tick.length— window size; only changes on grow/slide, not on every tick.gen— full-reload generation; bumped byloadBars/slot restores and rolling-window slides.
- Wired the plane from the existing authoritative paths:
loadBarspushes the validated view intobarPlane(with symbol/interval/exchange).appendBarpushes appends and slides intobarPlane(bumpGen: trueonly for slides).restoreSlotBarsToActivePlanerestores slot cache viabarPlane.flushLiveFrameupdates onlybarPlane.tipwhen a forming tick lands.
- Discovered and fixed a Solid-store proxy-link issue during the migration: when the same raw bar object was placed both in
barPlane.barsand inbarPlane.tip, and that object was also an element ofstore.bars, the two stores' proxies could interfere with the app store'sclosedmutation during index appends. The fix is to clone the plane'stipinsetBarPlaneFullso the narrow plane never exposes the exact same object instance as both the tail ofbarsand the live tip. - Migrated the highest-frequency UI readers from
store.bars/store.bars.lengthtobarPlane:- Shell chrome:
App.tsx(document title, rAF-throttled),StatusBar.tsx,Topbar.tsx. - Float/overlay panels:
PricePanel.tsx,DataViewPanel.tsx,QuotePanel.tsx,VolumeProfileOverlay.tsx,TimePanel.tsx. - Extras/modals:
CurrentPriceCard.tsx,BarReplayControls.tsx.
- Shell chrome:
store.barsremains the canonical source for engine evaluation, legacy components, andChartHost's full paint path during this migration. Phase 2 will decide whether to move the paint path to the plane or to the slot cache.
Tooling updates
package.jsonnow exposesbun checkas the canonical typecheck command (root, worker, and CLI). The oldbunx tsc --noEmitis retired for the standard alias.- Fixed
packages/cli/tsconfig.jsonwherebaseUrlnow emits a TS5102 error; replaced it with"paths": { "*": ["./*"] }sotypecheck:allpasses cleanly.
Validation after Phase 0/1
bun run typecheck:all— clean (563 app + 29 worker + 39 CLI files).bun run lint— exits 0 (warnings remain; no errors).bun run test:unit— 4708 pass / 10 skip / 0 fail.- Introduced zero new test failures; the only transient regression was the existing
store-closed-on-new-bar.test.tsassertingclosedmutation persistence, which exposed the proxy-link bug above and was fixed before commit.
Phase 2 — Rendering & data-plane performance (landed)
- Bar-plane append no longer copies the whole window. Added a targeted
setBarPlaneAppend(bar, index)path insrc/stores/bars.tsused byappendBarfor the common in-place append. The previous full-arraysetBarPlaneFullis still used for initial loads, slot restores, and rolling-window slides, plus a fallback when the plane length has drifted fromstore.bars(e.g. tests that writestore.barsdirectly). setDataToChartskips duplicate volume writes. When the candle series paint is skipped because the bar window is unchanged (skipSeries), the volume seriessetDatais now skipped too — it derives from the same bars.- Compare overlay avoids identical
setData.applyCompareOverlay(src/chart/compare-overlay.ts) now caches the last result frombuildCompareSeriesDataand short-circuits the LWC write when the built data is identical (which happens on unrelatedchartDataGen/label-pref effect re-runs). - On-chain scalar overlays avoid identical
setData.applyOnchainOverlays(src/chart/onchain-overlay.ts) fingerprints each line's point array and skipsseries.setDatawhen the fingerprint matches the last paint, while still re-applying visibility options. - On-chain event markers avoid full rebuilds.
applyOnchainEventMarkers(src/chart/onchain-events.ts) fingerprints the event set and skips the invisible host-seriessetData/marker-plugin rebuild when unchanged. - Ranging-aware overlay suppression in ChartHost. Added a reactive
isRangingsignal insrc/chart/ChartHost.tsxthat pollsPaneManager.isRanging()while mounted. The compare, on-chain scalar, and on-chain event effects now suppress full writes while the user is panning/zooming and flush the latest state once the gesture ends.
Validation after Phase 2
bun run typecheck:all— clean.bun run lint— exits 0.bun run test:unit— 4708 pass / 10 skip / 0 fail.
Phase 3 — Engine-boundary performance (landed)
- Off-thread engine JSON parsing. Added
parseEngineJsonAsyncinsrc/engines/json-sanitize.tsand a dedicatedsrc/engines/json-parse-worker.ts. Payloads above 256 KB are parsed in a module Worker using the samesanitizeNonFiniteJsonlogic, so main-thread blocking drops for large/run,/run/batch, and Pyodide result strings. Wired into:src/engines/live-sessions.ts(create/tick/append/status)src/engines/engine-ws.ts(inbound WebSocket frames)src/engines/catalog.ts(REST/runand Pyodide local path)src/engines/batch.ts(batch/run/batchenvelope)
- Typed-result contract for Pyodide. The local Pyodide path now runs its result JSON through
parseEngineJsonand lifts it withnormalizeEngineResult(src/indicators/run-helpers.ts) before returning, so REST/WS/Pyodide all present the sameNormalizedRunResultshape to the apply pipeline. - In-memory result cache keyed by
(scriptId, barsGen, inputs). Newsrc/indicators/run-cache.tsmemoizes successful non-live runs using a stable fingerprint of engine id, script id/script source hash, resolved inputs, engine config, bar-plane generation, bar window length, and resolved library sources. It is bounded (16 entries, 30 s TTL), clones cached values on store and retrieve to protect apply-stage mutations, and is automatically disabled in test environments to avoid cross-test pollution. Wired intorunScriptinsrc/indicators/runner.ts. - Pyodide streaming. True chunked/incremental result streaming is still outstanding; the immediate win is that the final large Pyodide JSON string now parses off the main thread. Streaming evaluation remains on the Phase 5 backlog.
Validation after Phase 3
bun run typecheck:all— clean.bun run lint— exits 0.bun run test:unit— 4708 pass / 10 skip / 0 fail.
Phase 4 — UX / visual polish (landed)
- Motion + safe-area tokens.
@themenow owns--ui-motion-duration-fast/base/slowand--ui-safe-top/bottom/left/right. Rawenv(safe-area-inset-*)call sites (mobile chrome, dialog sheets,FloatableShellphone geometry) read the tokens. Reduced motion collapses the duration tokens to0.01mson:root, htmland globally shortens animation/transition duration sotransitionendstill fires. - Drawing rail is compositor-only.
.axis-draw-railfades withopacity var(--ui-motion-duration-fast)instead ofmax-height/max-width. Hover-slide layout transitions (width/height/flex-basis/min-width) go throughhoverSlideTransition()and are skipped whenprefersReducedMotion()is true; CSS also setstransition: noneon.axis-panel-hover-slideunder reduced motion. Peek/expand still happens, instantly. - Theme tokens on chart scale chrome.
ChartScaleControlsuses--color-border,--color-accent,--color-text(-dim),--color-bg-panelinstead of hard-coded hex. - Shared
Spinner.src/ui/Spinner.tsxwraps the loader icon withrole="status",aria-busy, a stabledata-testid="axis-spinner", and a default "Loading" label. Wired into IndicatorCard, OnChainPanel, ResultsModal, ScriptSettingsModal, StorageChangeDialog, and SymbolModal. Busy refresh glyphs keepIcons.refresh+animate-spin. - Coarse-pointer topbar targets.
@media (pointer: coarse)raises command-bar / module / engine-switch buttons to a 40×40 px minimum withtouch-action: manipulation. - Modal focus restore + aria-hidden siblings.
installFocusTrapacceptsreturnElementand hides ancestor-chain siblings witharia-hidden="true"while the trap is active (already-hidden nodes stay hidden; previous values includingfalseare restored on dispose). Solidreffires before insert, so the sibling walk retries on a microtask once the dialog is in-tree.ModalpassesreturnElementthrough. Dispose order: unhide, then focusreturnElementor the previously focused element. Missingdocument.bodyis a no-op so test doubles keep working.
Validation after Phase 4
bun run typecheck:all— clean (566 app + 29 worker + 39 CLI files).bun run lint— exits 0 (warnings remain; no errors). New!importanton the global reduced-motion rule is intentional.- Phase 4 pins:
tests/spinner.test.ts,tests/phase4-ux-tokens.test.ts, plus extensions oftests/focus-trap.test.ts,tests/ui-modal-shell.test.ts,tests/panel-hover-slide.test.ts— 36 pass. bun run test:unit— 4723 pass / 10 skip / 1 fail. The failure isdata-source-manager > applyCachedToChart with onlyIfCurrent skips paint for a stale symboltiming out at 5 s (reproduced in isolation at ~6 s). It does not import Phase 4 UI files; treat it as a pre-existing slow DSM path, not a Phase 4 regression.
Phase 5 — Continuous quality gates (landed)
bun run perf:audit.scripts/perf-audit.mjsrunstypecheck, a hot-path unit slice (store-append-scale,live-append,store-live-frame,paint-latency), thenAXIS_BENCH=1 bun test tests/bench/. It scrapesAXIS_BENCH {json}lines from bench stdout and writesreports/perf-audit.json(gitignored).--full/bun run perf:audit:fullalso runstest:unitand Playwright@smoke.--skip-typecheck/--skip-benchexist for iterating on one side.- CI firehose budget.
.github/workflows/ci.ymladds a parallel Firehose budget job:bun run test:benchwithAXIS_BENCH=1, 10-minute job timeout. The existingexpect(sample.ms).toBeLessThan(FIREHOSE_SOFT_BUDGET_MS)(120 s) fails the job if the 500k-tick append path regresses into minutes. Dataset linear-merge keeps its 5 s soft budget. - ADR-017 — Narrow bar plane.
docs/architecture/adrs.mdxrecordsbarPlane(bars/tip/length/gen), the clone-on-full-write rule, and thatstore.barsstays the engine/eval source during migration. - ADR-018 — Engine result parse + typed contract. Records the 256 KB Worker parse threshold,
normalizeEngineResultas the apply envelope, and the bounded run-cache. Chunked Pyodide evaluation stays future work.
Validation after Phase 5
bun run typecheck:all— clean (566 app + 29 worker + 39 CLI files).- Phase 5 pins:
tests/perf-audit.test.ts— 9 pass. bun run perf:audit --skip-typecheck(this host, 2026-11-01): hot-path unit 441 ms; appendBar firehose 500k ticks in 19.1 s (26.1k ops/s, budget 120 s); dataset linear merge 50 pages in 829 ms (budget 5 s). Report:reports/perf-audit.json.
9. Next move
The six-phase audit plan is implemented through quality gates. Follow-ups that remain open: chunked Pyodide result streaming, typed-array engine envelopes, a nightly axis-nightly.yml if full e2e should stay off PR CI, and shrinking the DSM applyCachedToChart timeout. Keep requiring before/after measurements for any new hot-path change.