SVG vs Canvas Architecture

Pick the wrong rendering substrate for your dataset size and interaction model, and your dashboard either thrashes the layout engine into single-digit frame rates or forces you to reimplement hit-testing, accessibility, and styling by hand.

Concept Overview: Retained vs Immediate Rendering

Browser rendering engines diverge along two architectural paradigms, and the choice determines your memory ceiling, frame budget, and how much interactivity you get for free. This decision sits at the heart of the broader Core Rendering Engines & Tradeoffs overview, which maps each path onto the browser compositor; this guide drills into the specific contract between SVG and Canvas.

  • Retained-Mode (SVG): Maintains a declarative DOM tree where each visual element is a distinct node. The browser owns hit-testing, event delegation, focus, and compositing. This model excels at low-to-medium node counts (under ~2,000–5,000 interactive elements) where per-element interactivity and accessibility are primary requirements. Memory and layout cost scale linearly with node count.
  • Immediate-Mode (Canvas): Operates on an imperative pixel buffer. Drawing commands execute synchronously onto a single <canvas> bitmap. The engine retains no scene graph; you manually track geometry, manage redraws, and implement custom hit-testing. The DOM sees exactly one element regardless of how many points you draw.

Coordinate handling differs sharply. SVG natively supports viewBox transforms and CSS-based scaling, so it stays crisp at any zoom on high-DPI displays. Canvas requires explicit matrix transforms (ctx.save(), ctx.translate(), ctx.scale()) and manual Device Pixel Ratio (DPR) compensation to avoid subpixel blur. Mistaking one model’s idioms for the other’s is the most common source of architectural rework — the rule of thumb is that SVG gives you the DOM’s services at the DOM’s price, and Canvas gives you a blank, fast bitmap with no services attached.

The deeper consequence is where the work happens on each frame. With SVG, mutating an attribute pushes the element into the browser’s invalidation pipeline: style is recalculated, layout may reflow if a geometry attribute changed, the element repaints, and the compositor recomposites the affected layer. You never see this code, but you pay for it, and the cost is proportional to the number of mutated nodes and the depth of the subtree they live in. With Canvas you own the entire pipeline after clearRect: there is no style recalculation, no layout, and no per-shape compositing — just your draw calls flushed to a single GPU-backed surface. That is why a Canvas chart can hold 60fps with 50,000 moving points while the same workload in SVG collapses below 10fps long before you reach 10,000 nodes. The tradeoff is that everything the DOM gave you for free — focus, hover states, :hover CSS, tab order, screen-reader semantics — now has to be reimplemented against raw coordinates.

There is also a developer-velocity dimension that decision tables tend to omit. SVG charts are inspectable: you can open DevTools, hover a <circle>, and read its attributes, and you can style series with a stylesheet that designers can edit without touching JavaScript. Canvas is opaque to the inspector — debugging means logging coordinates or dumping toDataURL() snapshots — and every visual change is a code change. For an internal dashboard with a few thousand points that ships weekly, SVG’s iteration speed often outweighs Canvas’s raw throughput; for a trading terminal streaming tens of thousands of ticks per second, the calculus inverts completely.

Retained scene graph versus immediate bitmap SVG keeps one DOM node per shape with browser-managed events; Canvas issues draw commands into a single bitmap with manual hit-testing. SVG · retained mode Canvas · immediate mode <svg> one DOM node per shape events + a11y for free cost scales O(n) nodes best under ~5,000 nodes single bitmap draw commands, no graph manual hit-testing redraw cost O(n) draws scales to 100k+ points
SVG keeps a scene-graph node per shape with browser-managed events and accessibility; Canvas rasterizes everything into one bitmap that you must hit-test yourself.

Decision Table: SVG vs Canvas

Axis SVG (retained) Canvas (immediate)
Element count sweet spot < 5,000 interactive nodes 5,000 – 500,000+ points
Hit-testing Native, via DOM events on each node Manual: ctx.isPointInPath or distance math
Accessibility DOM tree exposes ARIA, tabindex, focus, screen readers None by default; needs offscreen DOM mirror
Styling Full CSS cascade, pseudo-classes, transitions Imperative per-frame style assignment
Memory model Heap + layout tree grows linearly with nodes One bitmap + your typed arrays
Partial updates Mutate individual attributes Dirty-rectangle bookkeeping required
High-DPI Crisp automatically (vector) Must scale backing store by DPR
Export XMLSerializer preserves vectors and text toDataURL rasterizes

Route datasets under ~5,000 nodes to SVG for native event handling and accessibility; switch to Canvas above that or for high-frequency animation. For the GPU tier beyond Canvas’s CPU ceiling, see the Canvas 2D vs WebGL comparison.

Treat the element-count thresholds in this table as starting points, not hard limits. The real ceiling depends on three multipliers: how often the nodes change, how deep each node’s subtree is, and what CSS is attached. A static 8,000-node SVG scatter that never animates can be perfectly smooth, because the layout cost is paid once at mount and never again; a 2,000-node SVG force graph that mutates cx/cy every tick can stutter, because each tick reflows the whole subtree. The interaction model matters as much as the count: per-element hover, drag, and focus strongly favor SVG because the browser routes those events for you, while global gestures like pan, zoom, and brush selection favor Canvas because they touch every element at once and there is nothing to gain from per-node DOM. When a single chart needs both — dense data and rich per-point interaction — the hybrid layering pattern below usually beats forcing either engine to do the other’s job.

Reference Spec: Core Signatures

Both substrates need a render function and a hit-test function. The signatures encode the architectural difference: SVG binds data to nodes, Canvas binds data to coordinates.

interface DataPoint {
  id: string;
  x: number;
  y: number;
  radius: number;
}

// SVG: data array reconciles against DOM nodes (D3 enter/update/exit).
declare function renderNodes(
  container: SVGSVGElement,
  data: ReadonlyArray<DataPoint>,
): void;

// Canvas: ordered shapes hit-tested against a cursor coordinate.
declare function hitTest(
  ctx: CanvasRenderingContext2D,
  mouseX: number,
  mouseY: number,
  shapes: ReadonlyArray<{ path: Path2D; id: string }>,
): string | null;

SVG Enter/Update/Exit (D3 v7)

import * as d3 from 'd3';

function renderNodes(container: SVGSVGElement, data: ReadonlyArray<DataPoint>): void {
  const selection = d3
    .select(container)
    .selectAll<SVGCircleElement, DataPoint>('circle.node')
    .data(data, (d: DataPoint) => d.id); // key fn prevents needless DOM reconciliation

  // EXIT: remove detached nodes to release layout + listener memory
  selection.exit().transition().duration(150).attr('r', 0).remove();

  // ENTER: create nodes with initial, accessible attributes
  const enter = selection
    .enter()
    .append('circle')
    .attr('class', 'node')
    .attr('cx', (d) => d.x)
    .attr('cy', (d) => d.y)
    .attr('r', 0)
    .attr('tabindex', 0) // A11Y: keyboard focusable
    .attr('role', 'img') // A11Y: exposed to screen readers
    .attr('aria-label', (d) => `Data point at ${d.x}, ${d.y}`);

  // UPDATE: merge and animate to final state
  enter
    .merge(selection)
    .transition()
    .duration(200)
    .attr('cx', (d) => d.x)
    .attr('cy', (d) => d.y)
    .attr('r', (d) => d.radius);
}

Canvas Raycasting Hit-Test

function hitTest(
  ctx: CanvasRenderingContext2D,
  mouseX: number,
  mouseY: number,
  shapes: ReadonlyArray<{ path: Path2D; id: string }>,
): string | null {
  // Iterate in reverse Z-order so the topmost shape wins.
  for (let i = shapes.length - 1; i >= 0; i--) {
    // PERF: isPointInPath uses the current transform; keep ctx state in sync with draw state.
    if (ctx.isPointInPath(shapes[i].path, mouseX, mouseY)) {
      return shapes[i].id;
    }
  }
  return null;
}

Step-by-step implementation

A robust pipeline needs deterministic initialization, a synchronized render loop, precise interaction routing, and a documented escape hatch to the GPU.

function initCanvasRenderer(canvas: HTMLCanvasElement): CanvasRenderingContext2D {
  const dpr: number = window.devicePixelRatio || 1;
  const { width, height } = canvas.getBoundingClientRect();
  canvas.width = Math.round(width * dpr);
  canvas.height = Math.round(height * dpr);
  const ctx = canvas.getContext('2d', { alpha: false })!;
  // PERF: scale once at init so all draw calls use CSS-pixel coordinates.
  ctx.scale(dpr, dpr);
  return ctx;
}

function startRenderLoop(ctx: CanvasRenderingContext2D, draw: (t: number) => void): () => void {
  let rafId = 0;
  const frame = (t: number): void => {
    draw(t);
    rafId = requestAnimationFrame(frame);
  };
  rafId = requestAnimationFrame(frame);
  // PERF: return a disposer so the loop never outlives the component.
  return () => cancelAnimationFrame(rafId);
}

Canvas dirty-rectangle redraw

Clearing and redrawing the whole bitmap on every cursor move burns the entire frame budget. Track invalidated regions and redraw only their union. The principle mirrors how the browser invalidates SVG — only what changed should be recomputed — but with Canvas you implement the bookkeeping yourself. The two failure modes to watch for are over-clearing (clearing the whole canvas because computing the dirty union felt like premature optimization) and under-clearing (forgetting to include the shape’s previous bounds, which leaves smear trails behind moving points). A correct dirty-rectangle pass unions both the old and the new bounds of every shape that moved, clears that union, and redraws every shape whose bounding box intersects it — not just the moved shapes, since neighbors may overlap the cleared region.

class CanvasRenderer {
  private readonly ctx: CanvasRenderingContext2D;
  private dirtyRects: DOMRect[] = [];
  private rafId: number | null = null;

  constructor(canvas: HTMLCanvasElement) {
    this.ctx = canvas.getContext('2d', { alpha: false })!;
  }

  invalidate(rect: DOMRect): void {
    this.dirtyRects.push(rect);
    if (this.rafId === null) {
      this.rafId = requestAnimationFrame(() => this.render());
    }
  }

  private render(): void {
    this.rafId = null;
    if (this.dirtyRects.length === 0) return;
    const b = this.mergeRects(this.dirtyRects);
    // PERF: clear and repaint only the invalidated union, not the full canvas.
    this.ctx.clearRect(b.x, b.y, b.width, b.height);
    this.drawShapesInBounds(b);
    this.dirtyRects = [];
  }

  private mergeRects(rects: DOMRect[]): DOMRect {
    return rects.reduce((acc, r) => new DOMRect(
      Math.min(acc.x, r.x),
      Math.min(acc.y, r.y),
      Math.max(acc.right, r.right) - Math.min(acc.x, r.x),
      Math.max(acc.bottom, r.bottom) - Math.min(acc.y, r.y),
    ));
  }

  private drawShapesInBounds(_bounds: DOMRect): void {
    /* redraw shapes intersecting _bounds */
  }
}

Hybrid: Canvas background + SVG overlay

Layer dense data on Canvas and float interactive chrome (brushes, tooltips, handles) on an SVG overlay so you keep raw throughput and native accessibility together. The discipline that makes this pattern work is keeping the layers in lockstep through one source of truth: a single transform (scale + translate) that both the Canvas draw loop and the SVG overlay apply. When the user pans, you update that transform once, redraw the Canvas with the new matrix, and set the SVG overlay’s <g transform="..."> to the same values; the two surfaces move as one. Set pointer-events: none on the Canvas so all input lands on the SVG layer, then translate cursor coordinates back into data space with the shared inverse transform before hit-testing. The most common alignment bug is applying DPR scaling to the Canvas backing store but forgetting that the SVG overlay works in CSS pixels — keep the DPR factor strictly inside the Canvas matrix and never leak it into the overlay’s coordinates.

function initHybridChart(container: HTMLElement): void {
  const canvas = document.createElement('canvas');
  canvas.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none;';
  container.appendChild(canvas);

  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
  svg.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;';
  svg.setAttribute('aria-label', 'Interactive chart overlay'); // A11Y: name the overlay layer
  container.appendChild(svg);

  const toLocal = (clientX: number, clientY: number): { x: number; y: number } => {
    const rect = container.getBoundingClientRect();
    return { x: clientX - rect.left, y: clientY - rect.top };
  };

  svg.addEventListener('pointermove', (e: PointerEvent) => {
    const { x, y } = toLocal(e.clientX, e.clientY);
    // PERF: defer overlay state to the next frame instead of mutating during the event.
    requestAnimationFrame(() => updateOverlayState(x, y));
  });
}

Performance & memory notes

  • Render cost. SVG attribute mutation is O(n) in changed nodes but each mutation can trigger style recalculation and layout; Canvas redraw is O(n) in draw calls but pays zero layout cost. Dirty-rectangle redraw drops the practical Canvas cost to O(k) for k changed shapes.
  • Hit-testing. SVG hit-testing is O(1) amortized (the compositor owns it); naive Canvas hit-testing is O(n) per pointer event. Add a quadtree or spatial hash to get Canvas back to roughly O(log n).
  • GC pressure. SVG leaks come from detached nodes retained by listeners or framework refs. Canvas leaks come from per-frame allocation — new ImageData, arrays, or objects inside requestAnimationFrame. Pre-allocate typed arrays and reuse Path2D instances.
  • Frame budget. Hold the full update inside the 16.67ms window. If a frame exceeds it, defer tooltip math, aggregation, or layout work to requestIdleCallback or a Web Worker — covered in DOM impact & reflow optimization.

Accessibility checklist

Troubleshooting

Symptom Root cause Fix
Cursor movement drops frames Clearing/redrawing the entire Canvas per pointer event Implement dirty-rectangle tracking or split static/dynamic layers
Memory grows after dataset swaps (SVG) remove() without detaching listeners or framework refs Detach listeners, clear refs, map node→data with a WeakMap
Blurry Canvas on Retina Backing store not scaled by devicePixelRatio Scale canvas.width/height by DPR and ctx.scale(dpr, dpr) once
Hit-test misses after zoom/pan Coordinates not mapped through the active transform Use SVGPoint.matrixTransform(svg.getScreenCTM().inverse()) or apply the inverse Canvas matrix
Safari drop-shadow tanks FPS filter: drop-shadow() on many SVG nodes hits the software rasterizer Apply filters to a wrapper <g> with filterUnits="userSpaceOnUse"

Frequently Asked Questions

When should I switch from SVG to Canvas?

Switch when interactive node count crosses roughly 5,000, or when high-frequency animation forces full-tree attribute mutation every frame. SVG’s per-node DOM cost makes layout and garbage collection the bottleneck past that point; Canvas collapses everything to one bitmap and one set of draw calls. For a workload-by-workload breakdown, see the companion guide on when to use SVG over Canvas for interactive dashboards.

Can I keep accessibility when I render with Canvas?

Yes, but you build it yourself. Canvas exposes no semantics, so mirror your data into an offscreen DOM structure — focusable proxy elements or an accessible data table — and synchronize focus and aria-live announcements with the bitmap. A hybrid layout that floats an SVG overlay on top of a Canvas plot is the most maintainable way to retain native focus handling.

Why is my Canvas blurry on high-DPI screens?

The CSS size and the backing-store size are different things. If you set only the CSS width/height, the browser upscales a low-resolution bitmap. Set canvas.width = cssWidth * devicePixelRatio (same for height), then call ctx.scale(dpr, dpr) once so your draw coordinates stay in CSS pixels.

Do I have to choose just one engine?

No. Production dashboards routinely composite both: a Canvas (or WebGL) layer for the dense data plot and an SVG overlay for axes, legends, brushes, and tooltips. Share a single transform matrix so the layers stay aligned during pan and zoom.