Status:
accepted · ADR-37 · Filed 2026-04-30Decision
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:- 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.
-
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 readmetricsHistoryviagetState()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 : curcould not advance fromcur=0).
- 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, andCHART_REDRAW_EVERYconstant 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).
- Custom tooltip plugin instead of Recharts’ declarative
<Tooltip>. Lives indashboard/web/src/components/charts/uplotShared.ts. Cursor-driven HTML overlay; updates imperatively fromsetCursorhook 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-contentouter + 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-seriesscaleassignment — slightly more wiring than Recharts’<YAxis yAxisId="left" />.
- StrictMode-safe. uPlot’s
destroy()callsroot.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 gatessetSizeto only fire on actual dim changes. Mount-only effect with a stable[]dep array; series-structure changes are signalled by the parent bumping akeyprop, which deterministically remounts. - Shared:
dashboard/web/src/components/charts/uplotShared.ts. Tooltip plugin (withvalueAthook 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)

