SVG is retained mode: the browser keeps every shape as a DOM element. Each frame, style resolves CSS for the elements, SVG layout computes geometry (there is no box model), and paint turns shapes into paths that a 2D library rasterizes (Skia 351,502 in Chrome 1 , which also draws Canvas 2D); filters and masks paint into offscreen layers first. Canvas 2D (Canvas 2D) is immediate mode: drawing calls go into a bitmap, and only pixels remain. The difference shows as elements pile up:
<svg id="svg" width="600" height="300"></svg><canvas id="cv" width="600" height="300"></canvas>
<script type="module">
const ctx = cv.getContext('2d'), xy = i => [i * 7919 % 600, i * 104729 % 300];
const time = async work => { // the work plus two rendered frames
const t0 = performance.now(); work();
await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
return `${(performance.now() - t0).toFixed(0)} ms`;
};
for (const n of [1000, 1000, 10000, 100000]) { // the first round warms up
const svgTime = await time(() => svg.innerHTML = Array.from({ length: n }, (_, i) =>
`<circle cx="${xy(i)[0]}" cy="${xy(i)[1]}" r="2"/>`).join(''));
const moveTime = await time(() => svg.firstChild.setAttribute('cx', 10));
const canvasTime = await time(() => {
ctx.clearRect(0, 0, 600, 300);
for (let i = 0; i < n; i++) { ctx.beginPath(); ctx.arc(...xy(i), 2, 0, 7); ctx.fill(); }
});
if (window.warm) console.log(`${n} circles: SVG ${svgTime}, moving one ${moveTime};`,
`canvas ${canvasTime}`);
window.warm = true;
}
</script>1000 circles: SVG 33 ms, moving one 33 ms; canvas 34 ms 10000 circles: SVG 65 ms, moving one 33 ms; canvas 34 ms 100000 circles: SVG 648 ms, moving one 135 ms; canvas 83 ms
Two frames take 33 ms, the floor of every row. At 100,000 circles SVG costs about eight times the canvas, and moving one circle takes eight frames (headless Chrome 154 on a shared machine: compare ratios). Keep interactive SVG to a few thousand elements; dense plots belong on Canvas, WebGL or WebGPU (Canvas 2D-WebGPU).