Let me start with the concession, because the version of this post that skips it is the version nobody senior finishes reading. SVG renders real-time telemetry at 60 frames a second perfectly well. On a reasonably fast machine you can put a couple hundred instruments on screen, update every one of them every frame, and the browser will hold 60fps without dropping anything. If you have read that SVG cannot handle high-frequency animation, that is not what the numbers say, and I went and got the numbers.
I still render the high-frequency parts of Altara, a telemetry component library I maintain, on Canvas. This post is about why, and the reason turns out to have almost nothing to do with frame rate. It is about where the work goes.
The comparison is really two questions
Every Canvas-versus-SVG post I have read makes the same mistake, and I nearly made it too. They compare "SVG" against "Canvas" as if those are two things, when the SVG side is almost always SVG driven through a framework's render cycle. In React that means every frame you set state, React reconciles the component tree, diffs the virtual DOM, and commits attribute changes to the real SVG nodes. When that turns out to be slow, the conclusion gets written up as "SVG is slow," which is the wrong conclusion, because you measured two costs and blamed one of them.
So I benchmarked three things, not two:
- svg-react: the SVG nodes live in React, and every frame triggers a re-render. This is what most people mean by "SVG" and it is the thing those posts actually measured.
-
svg-imperative: the SVG node tree is built once at mount, and every frame mutates it directly through refs,
setAttributefor the transforms and points,textContentfor the readouts, driven by a singlerequestAnimationFrameloop with no React in the hot path. -
canvas: one
<canvas>, one rAF loop, clear and repaint each frame, no React state involved in drawing.
All three draw the identical scene from identical per-frame math: N small widgets, each a rotating needle, a numeric readout, and a scrolling sparkline. The only variable is how the picture reaches the screen. Stripped down, the three hot paths look like this:
// svg-react: React owns the DOM, re-render the whole <svg> every frame
function Widget({ angle, value, points }) {
return (
<svg viewBox="0 0 100 100">
<line transform={`rotate(${angle} 50 50)`} x1="50" y1="50" x2="50" y2="10" />
<text x="50" y="60">{value}</text>
<polyline points={points} fill="none" />
</svg>
);
}
// driver: setState every frame -> React reconciles all N widgets
// svg-imperative: build once, keep refs, mutate in one shared rAF loop
needle.setAttribute('transform', `rotate(${angle} 50 50)`);
readout.textContent = value;
spark.setAttribute('points', points);
// canvas: one surface, one rAF loop, clear and repaint
ctx.clearRect(0, 0, width, height);
for (const w of widgets) drawWidget(ctx, w);
The reason three conditions matter is that the gaps between them are the answer:
svg-react - svg-imperative = the cost of React's render loop
svg-imperative - canvas = the cost of the SVG/DOM format itself
A two-way benchmark fuses those into one number and lets you tell whichever story you prefer. The three-way version makes you say both.
One caveat I want stated plainly rather than buried. Every number below is from a single machine, an Intel i9-9880H on Chromium 147 at devicePixelRatio 2, and your hardware will produce different numbers. The benchmark is committed to the repo and runs from one command, so the honest move is for you to run it yourself rather than trust my table. From a clone:
pnpm install && pnpm --filter @altara/demo dev # then open http://localhost:5173/bench/
Click "Run full matrix," or drive it headless with the Playwright runner in scripts/bench/.
What it measured
Frame rate is the wrong number to lead with, because it is quantized to the display. Everything below 16.7ms of work reads as a flat 60fps, which hides the entire story until the moment it falls off a cliff. The honest metric is per-frame main-thread work, the time your JavaScript actually occupies the main thread each frame. Here it is in milliseconds, with all three holding a clean 60fps through N=200:
| N | svg-react | svg-imperative | canvas |
|---|---|---|---|
| 1 | 0.45 | 0.22 | 0.07 |
| 50 | 3.84 | 1.86 | 0.39 |
| 200 | 10.51 | 5.18 | 1.07 |
Read the two gaps at N=200. React's render loop costs about 5.3ms (10.51 minus 5.18). The SVG/DOM format itself, mutation plus layout plus paint, costs about 4.1ms (5.18 minus 1.07). They are close to equal. That is the finding that kills both clichés at once. You cannot honestly write "SVG is slow," because half the cost was React. And you cannot honestly write "the problem was just React," because stripping React out left a 4ms format cost that is still roughly 5 times what Canvas spends. Both costs are real, and they stack.
Where each one falls over
Stay at 60fps long enough and eventually each strategy drops frames. Where, and why, is the part that decided it for me:
| mode | first drops at | at that point | bound by |
|---|---|---|---|
| svg-react | N ≈ 300 | 54fps, work 15.4ms | JS (reconcile + layout) |
| svg-imperative | N ≈ 600 | 46fps, work 14.5ms | JS (mutate + layout) |
| canvas | N ≈ 600 | 54fps, work 2.3ms | paint / compositor |
Look at the work column at the drop point. Both SVG strategies die with the main thread pinned near 15ms, because they ran out of JavaScript budget: reconciliation, attribute mutation, and the layout the browser recomputes afterward. Canvas drops frames with the main thread spending 2.3ms. It did not run out of JavaScript budget. It ran out of paint, the compositor rasterizing a 2880-by-840 surface at DPR 2. Even at N=1200, Canvas main-thread work is still only about 4ms.
That is the whole argument, and it is not "Canvas is faster." Canvas does not make frames free. It moves the bottleneck off the main thread. When SVG drops a frame, your JavaScript is the thing that overran, and you have zero milliseconds left for anything else your app needs to do that frame. When Canvas drops a frame, your JavaScript spent 2ms and the GPU is the constraint, which means you still have most of a frame's budget for state updates, network handling, and the rest of the application.
The part that actually matters: headroom on the machine you do not own
Your benchmarks run on your machine, and your machine is fast. Your users are on a five-year-old laptop with a browser full of tabs. So the number I care about is not what happens on the i9, it is what happens when the budget shrinks. Here is the same scene under 6x CPU throttle, which is a reasonable stand-in for mid-tier hardware:
| N | svg-react | svg-imperative | canvas |
|---|---|---|---|
| 50 | 36.7fps | 57.4fps | 60fps |
| 200 | 11.6fps | 18.8fps | 60fps (work 6.7ms) |
Imperative SVG, the genuinely good version with no React in the hot path, collapses to 18.8fps at N=200. Canvas holds a flat 60. This is the 10x main-thread headroom from the first table cashing out exactly where it counts. On a fast machine the headroom is invisible, all three look like 60fps. The headroom is not there to make the fast machine faster. It is there so the slow machine still works.
A confession about allocations
While I was measuring this I found a bug in my own library, and it is worth telling because it is the failure mode this whole post is about. My existing performance docs claimed the Canvas path was clean because it kept state out of the render loop. That is true and it is also not the same thing as being free.
The chart components buffer samples in a fixed-size RingBuffer, and the draw loop pulled the data out with getValues() and getTimes(), each of which allocates a fresh Float64Array. The draw pass called them twice per channel per frame, once to compute the y-extent and once to draw. Four typed-array allocations per channel per frame, at a default buffer of 10,000 samples, 60 times a second.
On the i9 this never dropped a frame. V8 absorbs short-lived typed arrays well enough that even 96 allocations a frame at 24 channels stayed locked at 60fps. It showed up only as GC work-spikes eating into the frame's spare budget, the headroom this entire post is an argument for protecting. Shrink the budget and those spikes become real drops. Under 6x throttle at 24 channels, the allocating version dropped 363 frames over 30 seconds, about 28fps. So I added a zero-copy read path:
// before: allocates two Float64Arrays per channel, per frame
const values = buffer.getValues();
const times = buffer.getTimes();
// after: fill reusable buffers allocated once, no per-frame allocation
const n = buffer.readInto(scratchValues); // returns the count written
buffer.readTimesInto(scratchTimes);
// draw from scratchValues[0..n]
Reading once per frame into reused scratch took per-frame allocation from four-per-channel to zero, removed every GC spike, and turned that throttled 28fps back into a flat 60. It shipped in @altara/core@0.2.0 as readInto and readTimesInto. I am including this not to pad the post with a victory lap but because it is the exact trap the headline is about. The benchmark proves Canvas keeps the main thread cheap. The library only delivers that headroom if it does not quietly hand it back to the garbage collector. "No state in the hot path" was a true sentence that let me believe a false one.
It is a ladder, not a verdict
The conclusion is not "use Canvas." It is "match the rendering strategy to the update frequency," and the same library makes that obvious if you look at how its components are actually built:
- The line charts and the flight display, which redraw everything every frame, are Canvas.
- The gauge, a single needle that moves occasionally, is plain SVG driven by React state. It updates rarely enough that reconciliation cost is irrelevant, and SVG gives it crisp scaling and simpler code for free.
- The event log and the status panels are ordinary DOM, because text that updates a few times a second has no business on a canvas, where you would lose selection, accessibility, and the browser's text layout for nothing.
Three tiers, chosen by how often the thing changes. Canvas is the right rung for moving line charts in the hundreds. It is the wrong rung for a needle that twitches once a second, and the wrong rung for a scrolling log.
Where Canvas stops
A deep dive that does not name its own ceiling is a sales pitch, so here is where this approach runs out.
The first wall is closer than you would expect, and it is self-inflicted. The chart draw loop currently emits one line segment per buffered sample. With a 10,000-sample buffer drawing into an 800-pixel-wide chart, that is more than ten times the geometry the display can resolve. The fix is decimation: bucket the buffer by pixel column and draw the min and max of each column, which caps drawn geometry at roughly twice the chart width regardless of how fast data arrives, and decouples draw cost from sample rate. One detail specific to telemetry: use min/max per column, not the prettier largest-triangle-three-buckets that general charting libraries reach for, because LTTB can drop a single-sample spike and in telemetry that spike is often the alarm you exist to display. This one is tracked as an open issue and is the subject of a later post, because most "we need WebGL for our charts" conclusions are really "we forgot to decimate."
Past that, the ladder continues. To move the 2D rasterization itself off the main thread you reach for OffscreenCanvas in a worker, which the library does not use yet. When you genuinely need to draw a hundred thousand primitives, dense scatter or a point cloud, 2D Canvas is paint-bound and you move to the GPU, which is exactly why the library's LiDAR point cloud renders through three.js and WebGL rather than a 2D context. And underneath all of it, today each chart runs its own independent rAF loop with no shared scheduler, so many components means many uncoordinated clear-and-repaint passes. Collapsing those into one scheduler is its own piece, on the architecture.
So the tiers go all the way up: DOM, SVG, 2D Canvas, WebGL, each one the right answer for a different load. The point was never that Canvas wins. It is that the format was never really the question. The question is where the work runs, and the reason to keep it off the main thread is the machine you will never get to test on.
The components are MIT licensed on npm as @altara/core and friends, and the benchmark, including the imperative-SVG condition and the allocation A/B, is in the repo and runs from one command, so you can disagree with my i9 on your own hardware.

Top comments (0)