Skip to content
microcharts

Performance

Zero dependencies, budget-gated sizes, and server rendering with no client JavaScript — 106 chart types at ~2–7 kB interactive · ~1–4 kB static, static SSR median about 0.03 ms each. Every number here is reproducible from one command.

Performance isn't a marketing line here — it's a build gate. Every figure on this page is produced by a command you can run yourself, checked against the shipped dist in CI, and regenerated on every release so it can never drift from reality.

106
chart types
0.03 ms
median / chart
146.3/ms
fastest
0
runtime deps

Size

Each chart is imported 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: a chart that grows past its budget fails the build.

interactive gzip · distributionstatic 0.973.99
1.95.06.6 kB
Count · catalog
105charts
Size range
1.96.6kB
median 5.03 · static median 2.6 · hover
Interactive median 5.03 kB across 105 entries. On the static twin, 12 of 106 charts ship under 2 kB; 81 under 3 kB. Above the 3 kB static mark — Sparkline (3.99 kB), Slope (3.86 kB), ForecastCone (3.58 kB), ChangePoint (3.55 kB), WinProbWorm (3.54 kB), DualSparkline (3.5 kB), BurnChart (3.41 kB), SpreadBand (3.34 kB), QueueDepth (3.32 kB), StationGlyph (3.29 kB), ControlStrip (3.28 kB), EnsembleGhosts (3.28 kB), PercentileLadder (3.27 kB), RetentionCurve (3.27 kB), NetFlow (3.23 kB), TapeGauge (3.2 kB), StackedArea (3.19 kB), SparkBar (3.17 kB), ShiftHistogram (3.17 kB), PolarClock (3.14 kB), ABStrips (3.09 kB), Constellation (3.09 kB), Dumbbell (3.04 kB), CyclePlot (3.03 kB), Waterfall (3.01 kB).

The interactive catalog lands between 1.88 kB and 6.64 kB gzip, with a median of 5.03 kB — hover, roving keyboard focus, select-to-pin, touch scrub, and live announcements from the /interactive subpath. The static twin lands between 0.97 kB and 3.99 kB, with a median of 2.6 kB — pure SVG, zero client JavaScript. A page pays for interactivity only where it actually 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 chartsChartStaticInteractive
smallestTokenConfidence0.97 kB1.88 kB
medianWindBarb2.60 kB kB
largestSparkline3.99 kB6.64 kB

Twenty-five charts sit above the 3 kB static reference line, none of them by more than 0.99 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, ShiftHistogram, StackedArea, ABStrips, PolarClock, Constellation, Dumbbell, CyclePlot, Waterfall, and TapeGauge. The annotation hosts carry a small shared walker that resolves Threshold, Marker, and the rest against the chart's scale — you pay for it only on those charts, and each is still smaller than a single icon font.

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 exactly 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 — so the split is a byte trade, never a behavior change. 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 — zero client JavaScript reaches the browser. Throughput, measured by pnpm bench rendering N sparklines to an SVG string:

ChartsTimePer chartPayload
1001.2 ms0.012 ms~572 B
5005.5 ms0.011 ms~572 B
100010.8 ms0.011 ms~572 B

A table of 500 sparklines becomes HTML in under six milliseconds, streams as part of the document, and ships nothing to hydrate. 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 almost nothing.

The core summary kernel is just as cheap: describeSeries, which writes every chart's accessible sentence, runs at roughly 892,000 calls per second (24-point series, measured by pnpm bench), so accessibility is never the thing that slows a render down.

Why it's fast

The speed isn't a trick — it's the absence of one.

  • 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. Nothing to resolve, nothing to audit, nothing to bloat a lockfile.
  • Integer geometry, crisp marks. Coordinates are rounded at generation and rectilinear marks use shape-rendering: crispEdges, so the browser does less work rasterising 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 — that's what keeps them RSC-safe — so they can't memoise internally: a parent re-render re-runs each chart's geometry. That's 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);

The catch is that memo only helps when props are actually 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 it.

We don't ship memo on the charts themselves for exactly that reason: it would tax the common case to speed up a specific one, and it's your call, not ours.

Honest caveats

  • The bench is one process, and that used to bite. Measuring 106 components in sequence let earlier charts leave 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 bench now 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 bench exits non-zero and names it.
  • Interactive entries do ship JavaScript. The /interactive subpath adds a client component and a small set of listeners on the wrapper — pointer, keyboard, and touch — plus 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's tiny, but it isn't zero — reach for the static entry unless a chart genuinely needs hover, keyboard, selection, or live announcements.
  • These numbers are one machine, one Node. They measure relative cost honestly and reproduce on your hardware; treat them as a floor to reproduce, not 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.json

CI runs --check on both synced files, so a stale number fails the build the same way a failing test does.