Skip to main content
Status: accepted · ADR-37 · Filed 2026-04-30

Decision

Live time-series charts in the dashboard render to HTML5 canvas via uPlot, not to SVG via Recharts. A thin React wrapper (UPlotChart.tsx) creates one uPlot instance per mount and calls setData(newData) on every push. No throttling layer between the data store and the chart. The React Flow topology graph stays on SVG (interactive, infrequent updates). Static / sparkline / one-shot charts may use either, but should default to uPlot when the chart re-renders more often than every few seconds.

Rationale

Two pressures pushed us off Recharts:
  1. SVG path rebuild on every update is the primary flicker source. Recharts re-renders the chart’s full path tree from React props on each data change. With the dashboard pushing metrics every 10 s and probes every 15 s, the SVG-rebuild-per-tick produced visible flicker on the live cards. uPlot maintains the chart instance across renders and updates by writing pixels to the existing canvas — no DOM mutation, no path swap.
  2. The throttle layer added to mask SVG cost was load-bearing for a problem that should not exist. The original implementation throttled redraws to every Nth push via a scalar (chartTick) that charts subscribed to instead of the data. Charts read metricsHistory via getState() only when the tick advanced. This coupling is fine when redraws are expensive; it is a leak when they are not. With uPlot the layer is unjustified, and removing it eliminated a pre-existing correctness bug in the throttle math ((cur+1) % N === 0 ? cur+1 : cur could not advance from cur=0).
Two reasons to prefer uPlot specifically over other canvas libraries:
  • Designed for streaming. setData(newData, false) exists for deferred-redraw pipelining; the API is built around the live-append case rather than retrofitted.
  • ~45 KB raw / ~12 KB gzip. Negligible bundle impact next to React + Vite’s per-chunk floor.

Consequences

Positive:
  • No flicker on live updates. Confirmed: 7 puppeteer frames over 1.5 s show identical canvas dims and contents during the steady-state push cadence.
  • Throttle scaffolding deleted: chartTick, healthChartTick, the subscribe-to-tick / getState()-data pattern, and CHART_REDRAW_EVERY constant are gone.
  • Charts subscribe directly to their data slice (metricsHistory, healthHistory). Re-render cadence equals push cadence; uPlot redraw is cheap.
  • Bundle: charts chunk ~110 KB gzip (down from ~120 with Recharts despite adding uPlot).
Negative / accepted costs:
  • Custom tooltip plugin instead of Recharts’ declarative <Tooltip>. Lives in dashboard/web/src/components/charts/uplotShared.ts. Cursor-driven HTML overlay; updates imperatively from setCursor hook to avoid React re-renders on hover.
  • Stacked area requires manual cumulative pre-computation (uPlot does not stack natively). Encapsulated in ThroughputChart.tsx.
  • Two layers (uPlot’s intrinsic width: min-content outer + canvas inside) require a wrapper CSS rule (.uplot-wrap > .uplot { position: absolute; inset: 0 }) to keep the canvas anchored when uPlot adjusts internal layout.
  • Dual-axis charts use uPlot’s scales: { left, right } plus per-series scale assignment — slightly more wiring than Recharts’ <YAxis yAxisId="left" />.
Neutral:
  • StrictMode-safe. uPlot’s destroy() calls root.remove() synchronously, so the dev-mode mount → cleanup → mount cycle leaves no DOM residue.

Alternatives Considered

  • Keep Recharts, add a double-buffer canvas swap. Rejected. Papers over the redraw cost rather than removing it; doubles the work; doesn’t future-proof if we drop the throttle to true 10 s live.
  • Chart.js / ECharts. Both work but are heavier (Chart.js ~50 KB gzip; ECharts ~150 KB gzip). uPlot is purpose-built for streaming time-series and is what Grafana / Netdata use for the same job.
  • Plain canvas with d3-scale. More code than uPlot, no built-in cursor / legend / axis features. Worth considering only if the chart needs become exotic enough that uPlot’s plugin API can’t cover them.

Implementation Notes

  • Wrapper: dashboard/web/src/components/charts/UPlotChart.tsx. ResizeObserver gates setSize to only fire on actual dim changes. Mount-only effect with a stable [] dep array; series-structure changes are signalled by the parent bumping a key prop, which deterministically remounts.
  • Shared: dashboard/web/src/components/charts/uplotShared.ts. Tooltip plugin (with valueAt hook for stacked charts that need to display un-cumulated values), default cursor, palette tokens.
  • Charts: ThroughputChart.tsx (stacked area), LatencyChart.tsx (multi-line w/ spanGaps: true), ErrorsChart.tsx (overlay areas), SaturationChart.tsx (dual-axis area + dashed line).

References

  • c8578a0 feat(dashboard): swap golden-signal charts to uPlot canvas
  • Retro: context/retrospectives/006-retro-dashboard-uplot-port.md
  • Library: https://github.com/leeoniya/uPlot (MIT, ~1.6.32)
Last modified on May 1, 2026