Performance
Where the size and speed numbers come from: 106 chart types at ~2–7 kB interactive · ~1–4 kB static, a static server render of about 0.03 ms each, and the commands that regenerate every figure.
Every figure on this page comes from a command you can run yourself. Each one is measured against the shipped dist,
re-checked in CI, and regenerated on every release.
Size
Each chart imports from its own subpath, so a bundle ships only the charts it uses: importing Sparkline never pulls in
the other 105. Sizes are gzip, measured by pnpm size and gated, so a chart that grows past its budget fails the build.
The /interactive subpath lands between 2.18 kB and 6.94 kB gzip, with a median of 5.24 kB. That buys hover,
roving keyboard focus, select-to-pin, touch scrub, and live announcements. Its static twin is pure SVG with zero client
JavaScript, and lands between 1.08 kB and 4.25 kB, with a median of 2.76 kB. A page pays for interactivity only
where it uses it. About 0.5 kB of the interactive delta is the shared picker kernel, which the per-subpath gate measures
standalone in every entry; a page using several interactive charts bundles it once.
| Across all 106 charts | Chart | Static | Interactive |
|---|---|---|---|
| smallest | TokenConfidence | 1.08 kB | 2.35 kB |
| median | BiasStrip | 2.76 kB | 5.29 kB |
| largest | Sparkline | 4.25 kB | 6.94 kB |
Thirty-two charts sit above the 3 kB static reference line, none of them by more than 1.25 kB. Sparkline, the most
configurable primitive in the set, is the largest. Most of the rest are value-series charts that host annotations
(ChangePoint, WinProbWorm, ForecastCone, DualSparkline, BurnChart, SpreadBand, RetentionCurve, ControlStrip, QueueDepth,
SparkBar, EnsembleGhosts, NetFlow, Slope), plus Station Glyph, PercentileLadder, PercentileTrace, ShiftHistogram,
StackedArea, ABStrips, PolarClock, Constellation, Dumbbell, CyclePlot, Waterfall, TapeGauge, MiniBar, BenchmarkStrip,
EventTimeline, VolumeProfile, GradedBand and LikertStrip; the last two crossed the line when their percent labels moved
onto a real Intl formatter. The annotation hosts carry a small shared walker that resolves Threshold, Marker, and
the rest against the chart's scale, and you pay for it only on those charts.
Splitting the stylesheet
@microcharts/react/styles.css is one artifact for the whole catalog, and importing it once is the right default: it is
small, it caches, and it never grows with the number of charts you use. If you ship one or two charts and want the rules
for the rest gone, there is an opt-in split. @microcharts/react/styles/core.css carries everything shared, and
@microcharts/react/styles/<slug>.css carries the rules specific to a single chart. Only the charts with chart-specific
CSS have a file; the rest need core.css alone.
import "@microcharts/react/styles/core.css";
import "@microcharts/react/styles/sparkline.css";core.css plus the slugs you import is equivalent to the matching subset of styles.css: same rules, same @layer
membership, same cascade order. The split trades bytes and changes no behavior. Import one or the other, not both.
Server rendering
Static charts are hook-free and listener-free, so a React Server Component renders them to plain SVG on the server and
zero client JavaScript reaches the browser. Throughput, measured by pnpm bench rendering N sparklines to an SVG
string:
| Charts | Time | Per chart | Payload |
|---|---|---|---|
| 100 | 4.1 ms | 0.041 ms | ~572 B |
| 500 | 5.5 ms | 0.011 ms | ~572 B |
| 1000 | 10.7 ms | 0.011 ms | ~572 B |
A table of 500 sparklines becomes HTML in under six milliseconds, streams as part of the document, and hydrates nothing. Across all 106 chart types the median static SSR render is about 0.03 ms per chart. The heavier expressive and frontier instruments cost more geometry; the simple strips cost close to nothing.
The summary kernel is just as cheap: describeSeries runs at roughly 840,000 calls per second on a 24-point series,
measured by pnpm bench. It writes the accessible sentence for every chart, so accessibility is not what slows a render
down.
Why it's fast
- No chart engine. No D3, no layout runtime, no virtual DOM diff for a static chart. Scales, paths, and stats are a few hundred lines of pure functions, and the output is a handful of SVG nodes, typically six or fewer per chart.
- Zero runtime dependencies.
dependencies: {}, enforced by CI, forever. There is no transitive tree to resolve, to audit, or to carry in a lockfile. - Integer geometry, crisp marks. Coordinates are rounded at generation and rectilinear marks use
shape-rendering: crispEdges, so the browser does less work rasterizing them. - One stylesheet, one cascade layer. Theming is CSS custom properties in a low-specificity
@layer, not inline styles recomputed per instance. - A scrub costs one render per unit crossed, not one per pointer move. Interactive entries put a single listener on the wrapper and resolve the unit with pure math, then bail out when the unit hasn't changed, so a sweep across a 24-point Sparkline re-renders at most 24 times no matter how many pointer events the browser delivers.
Re-rendering many charts
Static entries are hook-free, which is what keeps them RSC-safe, so they can't memoize internally: a parent re-render re-runs each chart's geometry. That is cheap per chart, but it adds up in a table of hundreds.
If you render many charts inside a component that re-renders for unrelated reasons, wrap them and keep data
referentially stable:
const Spark = memo(Sparkline);memo only helps when the props are stable. Pass a fresh array literal (data={[1, 2, 3]}) or JSX children and it
misses every time, and you pay the comparison on top of the render you were going to do anyway. Hoist the array, or skip
memo.
The charts don't ship wrapped in memo for that reason: it would tax the common case to speed up a narrow one, and the
call is yours to make.
Caveats
- The bench runs in one process, and that used to skew it. Measuring 106 components in sequence left the later ones
cold, and a median over windows short enough for one GC pause produced bimodal numbers: the same chart read 140 and 35
rows/ms on consecutive runs of identical code.
pnpm benchnow warms each component immediately before timing it and reports the fastest window, since GC and scheduler noise only ever slow a window down. Every chart clears its floor; if one stops,pnpm benchexits non-zero and names it. - Interactive entries do ship JavaScript. The
/interactivesubpath adds a client component, a small set of pointer, keyboard, and touch listeners on the wrapper, and the shared picker kernel that resolves them to a unit with pure math. There is still never a listener per data point, so a 30-point chart and a 365-cell grid cost the same. It is small but it isn't zero, so reach for the static entry unless a chart needs hover, keyboard, selection, or live announcements. - These numbers come from one machine and one Node. They measure relative cost and reproduce on your hardware; treat them as a floor to reproduce rather than an absolute to quote.
Reproduce it
Every figure above regenerates from the repo. Nothing is hand-keyed; the docs read the measured output directly.
git clone https://github.com/ganapativs/microcharts
pnpm install
pnpm build # build dist the numbers are measured against
pnpm bench && node scripts/sync-bench.mjs # SSR throughput → bench-summary.json
node scripts/sync-sizes.mjs # gzip budgets → chart-sizes.jsonCI runs --check on both synced files, so a stale number fails the build the same way a failing test does.
Accessibility
How microcharts names, announces, and navigates a chart: generated summaries, the decorative opt-out, the keyboard and live-region contract, color and locale, and system preferences.
Troubleshooting
Fixes for the common microcharts failures: unstyled charts, a chart invisible on a dark panel, a 'use client' error, the removed onPointFocus callback, an inert animate prop, a chart that won't fill its container, and serif labels.