I worked on an analytics product with about sixteen charts: survival curves, forest plots, histograms and scatter plots. Our charting library had not published a release in over a year. Nothing was broken, but I was worried about future React upgrades and who would maintain it. I wanted to know what we could move to.

I took two charts from the product, a survival curve and a scatter plot, and built each with nivo, Recharts, ECharts, visx, TanStack Charts and Chart.js. I also built them in React with hand-written SVG. All seven implementations followed the same spec and used the same synthetic data, with animation off.

At typical sizes (three survival series with 200 points each, and a 500-point scatter) the native-speed timings gave me no clear winner. Within each chart type, all seven were less than one frame apart.

At 10,000 scatter points, the median mount times spread out: 24 ms for hand-written SVG, 33 ms for visx, 53 ms for TanStack Charts, 80–86 ms for Chart.js and ECharts, 310 ms for Recharts and 3.2 seconds for nivo's SVG scatter. Both Canvas implementations took longer than my hand-written SVG. nivo also has a Canvas scatter renderer; I did not test it.

Those are measurements from headless Chromium on my M3 Pro with other work running. They helped narrow my choices for these two chart types. I also compared how much code each implementation needed and how much JavaScript it loaded.

How I measured

Every comparison I found was a feature checklist or one console.time around a render. I wanted numbers I could defend in a design review. Getting them took more work on the stopwatch than on the charts.

What was tested

  • The survival curve has step lines, a shaded confidence band per series, small ticks for censored observations, and a tooltip with every series' value at the pointer.
  • The scatter plot has circles sized per point and coloured per group, with a nearest-point tooltip.

The versions were nivo 0.99, Recharts 3.10, ECharts 6.1, visx 4.0, TanStack Charts 1.2 and Chart.js 4.6. The hand-written version uses React and SVG with no d3. All seven were written against one spec: same size, same marks, same tooltip content. Each library was used the way its docs recommend.

Every implementation gets byte-identical data from a seeded generator, at two sizes. Typical is 3 series × 200 points and 500 scatter points. Heavy is 6 series × 1,000 points and 10,000 scatter points.

Each cell is one warm-up load and nine measured page loads of a production build in headless Chromium, reported as the median. I ran everything twice: at native speed, and with Chrome's 4× CPU slowdown, to make the differences easier to see.

I ran this on a busy laptop, with the libraries in a fixed order while the load changed, and I have no measured noise figure. The comparison against an earlier, quieter run is in report.md.

My rule of thumb: call it a tie if the gap is under one frame, or if a heavy-chart result took less than 20% longer than the faster one. That cutoff is my judgement, not a measurement.

The stopwatch

The instrument is a small package, bench-stage. Its core:

const requestSettle = useCallback(() => { if (requestedRef.current) return; requestedRef.current = true; requestAnimationFrame(() => { requestAnimationFrame(() => { performance.mark('bench:settled'); performance.measure('bench:mount', 'bench:start', 'bench:settled'); }); }); }, []); 

I measured it this way:

  1. The chart is not rendered on page load. The runner starts the clock when it mounts the chart, after page startup and module evaluation.
  2. The clock stops two animation frames after the chart reports done. The first callback runs after React's commit, the second after the browser has produced a frame with the chart in it. The harness does not watch pixels reach the screen; this is a convention, applied to every library in the same way.
  3. SVG and Canvas report "done" differently and then stop the clock the same way. An SVG chart is done when React commits. A Canvas chart is done when the library says it has drawn: ECharts' finished event, or a Chart.js plugin's afterRender. Both then go through the same double requestAnimationFrame. Marking straight from the Canvas callback would have given every Canvas library a free frame.

The reason for waiting is that React's commit is not the end of the work. Recharts' first commit put 2 of the chart's 30,109 DOM nodes in the page; the rest arrived about 295 ms later, before any frame was drawn. A timer stopped at the commit would have missed almost all of it.

The price of this timer is that it counts in frames. An empty page with no chart measures 22–26 ms, so anything less than one frame (16.7 ms) apart is a tie.

Results

Full tables for both speeds are in report.md.

Mount time with the 4× CPU slowdown (median ms, empty page ≈ 21–25 ms)

library survival typical survival heavy scatter typical scatter heavy
no library 21.3 27.2 23.2 103.6
visx 22.2 51.7 21.3 127.9
TanStack Charts 20.9 90.6 20.1 195.9
Chart.js 32.7 60.2 36.8 318.1
ECharts 49.2 123.7 48.0 260.9
nivo 62.7 149.7 134.8 14,899.8
Recharts 103.1 446.1 111.7 1,195.8

In the typical columns, hand-written SVG, visx and TanStack are one group. Recharts and nivo's scatter sit well above it.

On the heavy scatter, visx took 23% longer than plain SVG and Chart.js took 22% longer than ECharts. Both are just past my 20% line, and in an earlier run the same day both pairs were inside it, so I call them ties.

On the heavy survival chart plain SVG has the lowest median, but two of its nine runs landed at 53–55 ms, inside visx's range. I don't count that as a clean lead.

At native speed, the typical timings for each chart were all less than one frame apart: 19–29 ms for the survival chart and 22–37 ms for the scatter. For these two charts at typical sizes, that is too close for me to rank.

The 10,000-point scatter did separate them: 24 ms with hand-written SVG, 33 ms with visx, 53 ms with TanStack, 80–86 ms with the two Canvas libraries, 310 ms with Recharts and 3.2 s with nivo. That is nivo's SVG scatter with animation off; nivo also ships a Canvas scatter, which I did not test.

Canvas did not win at 10,000 points. The ECharts and Chart.js implementations mounted about twice as slowly as visx and plain SVG, or worse, at both CPU speeds.

Hover (median of each sweep's longest frame, 4× slowdown)

The pointer sweeps the chart in 60 steps and the harness records the longest frame of each sweep. Most cells are one frame. On the 10,000-point scatter Recharts takes 133 ms (8 frames), Chart.js and visx 50 ms, plain SVG and TanStack 33 ms. nivo and ECharts stay at one frame; nivo hit-tests against a precomputed Voronoi mesh.

Weight and code

library library cost (gzip) code tokens for both charts DOM nodes, 10k scatter
no library 2.2 KB 3,134 10,075
visx 25.0 KB 1,683 10,117
Chart.js 55.6 KB 1,214 2 (canvas)
TanStack Charts 56.8 KB 720 10,055
nivo 103.1 KB 993 10,094
Recharts 106.2 KB 1,064 30,109
ECharts 164.2 KB 1,117 5 (canvas)

Library cost is the gzipped JS a page loads, minus the same page with no chart library. It is the cost of these implementations and their imports. TanStack's figure includes about 15 KB for its motion renderer, which only the playground's effects mode uses but the timed pages still load.

Code tokens count both charts with their tooltips, after TypeScript turns the code into plain JS, without imports, comments or whitespace. The no-library count overstates what those two charts need, because it includes shared axis code for the playground's other charts.

What each library made me do

nivo. Bands and censoring ticks had to be custom layers. The linear scale is nice by default and quietly stretched my x axis from 412 to 450. Slices only group points with exactly the same x, so the survival tooltip became my own lookup.

Recharts. Survival data had to be reshaped into one row per x and forward-filled. Skip that and the shared tooltip silently drops any series with no point at that exact x. Animation has to be switched off on every element separately. It renders three DOM nodes per scatter point.

ECharts. A misspelled top-level option key type-checks cleanly, because the option type has an index signature, and then throws inside setOption. Misspelled nested keys are just ignored.

visx. Everything is composed from primitives, and the hit-testing is your own code.

TanStack Charts 1.2. A mark grammar in the Observable Plot tradition. Censoring ticks are a stock tickX, and the band is areaY with y1 and y2:

marks: [ decorative(areaY(rows, { x: 'x', y1: 'lower', y2: 'upper', z: 'series', fillOpacity: 0.15, curve: stepAfter })), lineY(rows, { x: 'x', y: 'y', z: 'series', curve: stepAfter }), decorative(tickX(rows.filter(r => r.censored), { x: 'x', y: 'y', z: 'series', length: 8 })), ], 

Chart.js. Almost everything is native, including censoring ticks as a rotated pointStyle: 'line'. Watch the naming: stepped: 'before' is what d3 calls step-after. The survival tooltip needed a custom interaction mode.

What I would pick

For another app with these two chart types, this is my shortlist.

If the goal is less code, I would prototype TanStack Charts first: 720 tokens, and nearly every requirement was a stock mark. Its first npm release was on 29 July 2026, so the risk is its age.

If the goal is control and a small bundle, I would take visx, or no library at all. They were the two fastest on the big scatter and the smallest. The cost is code: 1,683 tokens with visx and 3,134 with nothing.

I would not put a large scatter on nivo's SVG renderer. 10,000 points took 3.2 s, and its last npm release, 0.99.0, was on 23 May 2025.

With Recharts I would check two things before committing. The survival tooltip needs a data reshape that fails silently if you forget it. And in the stress cases here it was the slowest library on the heavy survival chart and the worst on hover at 10,000 points.

And if you benchmark charts yourself: stop the clock after the browser has drawn the chart, and send SVG and Canvas through the same last step.

Limits

  • One machine, one browser engine, headless, and the machine was busy. The libraries always ran in the same order.
  • Two chart types. A product full of bar charts or dual axes could rank differently.
  • Code tokens measure what this spec needed, written the way each library's docs suggest. Someone who knows one library well might need fewer.
  • Hover is a scripted sweep and not real input latency.

Try it

The repository has the harness, all seven ports, the raw data for every run and the scripts that build these tables. There is also a playground: paste your own data as JSON, pick the libraries, and it measures each one alone with a fresh page load per run, across twelve chart types.