Memory Management in Heavy Charts

Get teardown wrong in a long-running dashboard and every navigation leaks a chart — heap usage climbs, frame rates decay, and the tab eventually crashes or loses its GPU context.

Concept Overview: Three Memory Layers, Three Failure Modes

Heavy interactive visualizations allocate across three independent layers, and each one leaks differently. The JavaScript heap holds dataset arrays and object graphs; the DOM tree tracks layout and accessibility semantics for SVG nodes; and GPU VRAM holds rasterized pixels, textures, and vertex buffers for Canvas and WebGL. Understanding how allocation patterns diverge across rendering backends is a core part of the broader Core Rendering Engines & Tradeoffs decision, because the engine you pick dictates which layer becomes your memory ceiling.

The practical thresholds are concrete. V8 typically triggers a garbage collection (GC) pause when heap usage exceeds roughly 70% of its configured limit (commonly 1.5–4 GB depending on the device), and a WebGL context is lost when VRAM allocation surpasses driver-imposed caps. A chart that allocates a fresh Float32Array or ImageData every frame fragments the heap; one that never calls gl.deleteBuffer orphans VRAM until the driver forcibly resets the context. The job of memory management is to keep all three layers flat across the full lifecycle: mount, stream, pan, zoom, and unmount.

These layers also fail on different timescales, which is why a single profiling tool rarely catches every leak. Heap fragmentation accumulates over minutes of streaming and surfaces as lengthening GC pauses — a major collection (a “mark-sweep-compact”) can stall the main thread for tens of milliseconds, blowing several consecutive 16.6ms frames at once and producing the characteristic periodic stutter of a leaking dashboard. Detached DOM growth is slower and quieter: it costs a few kilobytes per orphaned subtree and only becomes visible after dozens of navigations, but it simultaneously inflates querySelectorAll, mutation-observer, and accessibility-tree traversal costs. VRAM exhaustion is the most abrupt of the three — there is no graceful degradation, only a webglcontextlost event and a blank canvas. Because the symptoms diverge, the discipline is to instrument all three from the start rather than chase whichever one happens to crash first.

A useful mental model is ownership: every allocation must have exactly one owner responsible for releasing it. A Float32Array is owned by the buffer pool that lent it; an SVG node is owned by the selection that appended it; a WebGLTexture is owned by the resource manager that created it. Leaks are almost always ownership ambiguity — two code paths assume the other will free a resource, or a closure quietly becomes a second owner that never relinquishes its claim. Writing teardown as the mirror image of setup, in reverse order, makes ownership explicit and auditable.

Three memory layers and what leaks in each JS heap leaks via closures and arrays, the DOM tree via detached nodes, and GPU VRAM via undeleted buffers, each with its own failure threshold. JS Heap DOM Tree GPU VRAM Leaks via closure-trapped arrays live rAF loops module-scoped caches Leaks via detached SVG nodes orphaned listeners stale ARIA refs Leaks via undeleted buffers undeleted textures leaked framebuffers Threshold GC pause > 70% limit Threshold reflow > 10k nodes Threshold context loss at cap
Each memory layer has a distinct leak vector and a distinct failure threshold; a complete teardown must address all three.

A quick baseline probe lets you watch heap and node counts move across an interaction:

// Monitor baseline memory allocation before triggering GC or context loss
interface MemoryBaseline {
  heapUsedMB: number;
  domNodes: number;
}

export function getMemoryBaseline(): MemoryBaseline {
  // performance.memory is Chromium-specific; fall back to 0 for cross-browser safety
  const mem = (performance as Performance & { memory?: { usedJSHeapSize: number } }).memory;
  const heapUsedMB: number = mem ? mem.usedJSHeapSize / 1048576 : 0;
  const domNodes: number = document.querySelectorAll('*').length;
  return { heapUsedMB, domNodes };
}
// PERF: Call this at most once per 500ms; querySelectorAll('*') itself walks the tree.
// A11Y: High DOM node counts correlate with increased screen reader traversal latency.

Leak Vectors by Rendering Backend

The detection tool and the fix change entirely depending on which backend you chose for the chart.

Backend Primary leak vector How to detect Fix
SVG / DOM Detached subtrees retained by listeners or framework refs DevTools Memory → filter Detached selection.exit().remove(), removeEventListener, null refs
Canvas 2D Uncanceled requestAnimationFrame loops, per-frame ImageData/OffscreenCanvas allocation Allocation timeline shows linear array growth cancelAnimationFrame, fixed-size buffer pool, reuse contexts
WebGL WebGLBuffer/WebGLTexture never deleted; orphaned framebuffers webglcontextlost events; VRAM in GPU profiler gl.deleteBuffer/gl.deleteTexture, LRU eviction, context-loss handlers

The rule of thumb: SVG leaks are visible in the DOM and the heap, Canvas leaks live in the heap and CPU loop, and WebGL leaks hide in VRAM where standard heap snapshots cannot see them.

This split has a direct operational consequence: your leak budget should be measured per backend, not globally. For an SVG chart, the canonical health metric is detached-node count, which should return to zero after teardown and GC. For Canvas, it is the count of live requestAnimationFrame callbacks plus per-frame array allocations on the timeline, both of which should flatten when the chart is idle. For WebGL, it is the delta in GPU memory reported by the browser’s GPU process between mount and unmount, which standard JavaScript instrumentation cannot read at all. A dashboard that mixes backends — SVG axes over a Canvas plot over a WebGL heatmap, a common hybrid — inherits all three leak vectors at once, so its teardown routine must explicitly address each surface rather than assuming one cleanup pass covers everything.

DOM and SVG Node Lifecycle Management

SVG-based charts rely on the browser’s layout engine, so every <circle>, <path>, or <g> consumes both heap and layout-tree memory. Beyond ~10,000 nodes, DOM retention degrades scroll performance and raises GC frequency — exactly why the SVG vs Canvas Architecture choice directly sets your memory ceiling. Proper lifecycle management requires explicit node removal, event detachment, and weak-reference caching so detached subtrees do not survive route transitions.

import { select } from 'd3-selection';

interface ChartDatum {
  id: string;
}

// Safe SVG teardown with selection exit and listener detachment
export function teardownSvgChart(
  container: HTMLElement,
  handleHover: (e: Event) => void,
): void {
  const root = select<HTMLElement, unknown>(container);

  // 1. Exit pattern: removes nodes from DOM and frees layout memory
  root
    .selectAll<SVGElement, ChartDatum>('.chart-node')
    .data<ChartDatum>([], (d) => d.id)
    .exit()
    .remove();

  // 2. Detach custom event listeners to prevent closure leaks
  const nodes = container.querySelectorAll<SVGElement>('.chart-node');
  nodes.forEach((node) => {
    node.removeEventListener('mouseenter', handleHover);
    node.removeEventListener('mouseleave', handleHover);
  });
  // 3. WeakMap entries are GC'd automatically once their key nodes go out of scope.
}
// PERF: selection.exit().remove() synchronously drops DOM references, preventing
// detached-tree accumulation that causes memory bloat.
// A11Y: Remove aria-describedby and tabindex before node removal to avoid orphaned ARIA relationships.

Canvas 2D Buffer Pooling and State Reset

Canvas 2D bypasses the DOM and shifts pressure onto the JavaScript heap and the compositor. High-frequency redraws that allocate a new ImageData or OffscreenCanvas per frame fragment the heap fast. Pool fixed-size buffers, reset state instead of reallocating, and always cancel pending animation frames during teardown so orphaned render loops stop burning CPU and memory.

// Fixed-size buffer pool for transient chart elements
class CanvasBufferPool {
  private readonly pool: OffscreenCanvas[] = [];
  private readonly ctxPool: OffscreenCanvasRenderingContext2D[] = [];

  constructor(size: number, width: number, height: number) {
    for (let i = 0; i < size; i++) {
      const canvas = new OffscreenCanvas(width, height);
      this.pool.push(canvas);
      this.ctxPool.push(canvas.getContext('2d')!);
    }
  }

  acquire(index: number): { canvas: OffscreenCanvas; ctx: OffscreenCanvasRenderingContext2D } {
    const idx = index % this.pool.length;
    const ctx = this.ctxPool[idx];
    // PERF: Reset state rather than reallocating to stay within the 16.6ms frame budget.
    ctx.clearRect(0, 0, this.pool[idx].width, this.pool[idx].height);
    ctx.globalAlpha = 1;
    ctx.setTransform(1, 0, 0, 1, 0, 0);
    return { canvas: this.pool[idx], ctx };
  }
}

// Teardown: cancel rAF loops to stop unbounded allocation
let rafId: number | null = null;
export function stopRenderLoop(): void {
  if (rafId !== null) {
    cancelAnimationFrame(rafId);
    rafId = null;
  }
}
// PERF: Reusing OffscreenCanvas contexts eliminates per-frame ImageData allocation spikes.
// A11Y: Update fallback text or aria-label before a buffer swap to keep screen reader context.

WebGL Texture and Buffer Lifecycle Control

WebGL pushes memory management entirely onto the developer. The WebGLRenderingContext does not garbage-collect buffers or textures; they must be deleted explicitly to free VRAM. Skip cleanup on unmount or dataset swap and GPU resources orphan, eventually triggering webglcontextlost. When scaling beyond ~500k vertices, vertex-attribute and VRAM-limit awareness — detailed in WebGL Fundamentals for Visualizations — becomes mandatory. Use LRU eviction for dynamically generated textures and always bind context loss/restore handlers.

// WebGL resource disposal and context loss handling
export class WebGLResourceManager {
  private buffers: WebGLBuffer[] = [];
  private textures: WebGLTexture[] = [];
  private readonly gl: WebGLRenderingContext;

  constructor(gl: WebGLRenderingContext) {
    this.gl = gl;
    (gl.canvas as HTMLCanvasElement).addEventListener('webglcontextlost', (e: Event) => {
      e.preventDefault(); // Required by spec to allow restoration
      this.clearAll(); // Drop stale references during context loss
    });
    (gl.canvas as HTMLCanvasElement).addEventListener('webglcontextrestored', () => {
      this.reinitializeResources();
    });
  }

  disposeBuffer(buf: WebGLBuffer): void {
    this.gl.deleteBuffer(buf);
    this.buffers = this.buffers.filter((b) => b !== buf);
  }

  disposeTexture(tex: WebGLTexture): void {
    this.gl.deleteTexture(tex);
    this.textures = this.textures.filter((t) => t !== tex);
  }

  clearAll(): void {
    this.buffers.forEach((b) => this.gl.deleteBuffer(b));
    this.textures.forEach((t) => this.gl.deleteTexture(t));
    this.buffers = [];
    this.textures = [];
  }

  private reinitializeResources(): void {
    // PERF: Re-upload VBOs and recompile shaders here; do not allocate inside rAF.
  }
}
// PERF: Explicit gl.delete* calls prevent VRAM fragmentation and avoid context loss during heavy pan/zoom.
// A11Y: WebGL renders to a single <canvas>; keep role="img" and a descriptive aria-label across context restoration.

Reference Spec — Teardown Signatures

Function / method Parameters Returns Purpose
teardownSvgChart container: HTMLElement, handleHover: (e: Event) => void void Exit-remove nodes, detach listeners
WebGLResourceManager.disposeBuffer buf: WebGLBuffer void Delete one VBO, drop reference
WebGLResourceManager.disposeTexture tex: WebGLTexture void Delete one texture, drop reference
WebGLResourceManager.clearAll void Delete all GPU resources on unmount
stopRenderLoop void Cancel the active requestAnimationFrame

Step-by-step implementation

A complete teardown touches all three layers in order. Walk this checklist on every component unmount.

interface ChartHandles {
  rafId: number | null;
  container: HTMLElement;
  gl: WebGLRenderingContext | null;
  resources: WebGLResourceManager | null;
  observers: Array<ResizeObserver | IntersectionObserver>;
}

// Full lifecycle teardown across heap, DOM, and VRAM
export function destroyChart(h: ChartHandles): void {
  // 1. Cancel the render loop
  if (h.rafId !== null) cancelAnimationFrame(h.rafId);

  // 2. Exit-remove SVG nodes and clear container
  select(h.container).selectAll('*').remove();

  // 3. Detach observers
  h.observers.forEach((o) => o.disconnect());

  // 4. Delete GPU resources, then force context loss
  if (h.gl && h.resources) {
    h.resources.clearAll();
    h.gl.getExtension('WEBGL_lose_context')?.loseContext();
  }

  // 5. Null references so the heap can be reclaimed
  h.resources = null;
  h.gl = null;
  // PERF: Nulling module-scoped caches is what lets V8 actually free the dataset arrays.
  // A11Y: Confirm any aria-live region the chart created is removed so screen readers stop announcing it.
}

Performance & Memory Notes

V8 caps the JavaScript heap between roughly 1.5 GB and 4 GB depending on the device and flags, but practical limits sit far lower due to fragmentation. GC pauses become noticeable as live heap approaches ~70% of the limit, so the goal is a flat retained-size curve across interactions, not a low absolute number. Typed arrays (Float32Array, Uint16Array) store coordinates in contiguous memory and bypass V8 hidden-class overhead, making pooled typed-array buffers the cheapest streaming primitive.

Object pooling changes the asymptotic allocation cost, not just a constant factor. Allocating one object per data point per frame is O(n) allocations and O(n) eventual GC work every frame; a pool turns steady-state allocation into O(1) because the working set is created once and recycled. The same logic applies to typed arrays: pre-sizing a single backing Float32Array to the maximum expected point count and writing into a subarray view costs zero allocations during streaming, where repeatedly calling new Float32Array(n) would generate n × frame-rate bytes of garbage per second. WeakMap and WeakRef give you the opposite guarantee on the read side — a node-to-datum mapping stored in a WeakMap never keeps its key node alive, so when the node is removed the entry disappears with it and you avoid the classic cache-as-leak failure where a lookup table outlives the data it described.

There is a real tradeoff to pooling, however: a pool holds memory resident even when idle, trading peak heap for allocation smoothness. Size pools to the realistic working set, not the theoretical maximum, and release them on unmount like any other resource. For genuinely unbounded streams, prefer a fixed-size circular buffer that overwrites the oldest samples — bounded memory is more valuable than complete history on a live dashboard.

Complexity matters at scale. A naive full redraw is O(n) per frame; with n at 100k and a 16.6ms budget you have ~166ns per point, which forces dirty-rectangle or instanced rendering. DOM node creation is super-linear once layout recalculation kicks in, which is why SVG flattens out near 10k nodes. VRAM is the silent ceiling: textures cost width × height × 4 bytes, so a handful of full-screen offscreen targets on a high-DPI display can exhaust a mobile GPU’s budget and force a context loss.

Accessibility Checklist

Troubleshooting

Symptom Root cause Fix
Heap grows on every route change Detached SVG subtree retained by a listener or framework ref Run exit().remove(), removeEventListener, and null refs in cleanup
FPS degrades after repeated mounts Old requestAnimationFrame loops never canceled Store and cancelAnimationFrame the id on unmount
webglcontextlost after heavy pan/zoom Buffers/textures never deleted; VRAM exhausted Call gl.deleteBuffer/gl.deleteTexture; add LRU eviction
Memory flat in snapshots but tab still bloats VRAM leak invisible to heap snapshots Profile the GPU process; verify clearAll() runs on teardown
Retained size > 0 after chart.destroy() Module-scoped cache or closure still holds the dataset Null caches and avoid capturing arrays in long-lived closures

Frequently Asked Questions

Why does my heap keep growing even after I call remove() on the chart?

remove() detaches the DOM nodes, but if an event listener, a d3 selection, or a framework ref still points at them, V8 cannot reclaim the subtree — it becomes a detached tree. Detach every listener, clear refs, and verify in DevTools Memory by filtering for Detached. A retained size above zero after teardown means a reference path is still live.

How do I find a WebGL VRAM leak when heap snapshots look clean?

Heap snapshots only see the JavaScript heap, not VRAM, so a leak of WebGLBuffer or WebGLTexture objects is invisible there. Watch for webglcontextlost events and profile the GPU process in your OS or browser task manager. The fix is to track every GPU handle and call gl.deleteBuffer/gl.deleteTexture on teardown, ideally through a resource manager.

What is the safe upper bound for SVG nodes before I should switch to Canvas?

Around 10,000 interactive DOM nodes, layout recalculation and GC pressure begin to dominate the frame budget. Past that, move dense data to a Canvas or WebGL surface and keep SVG only for interactive chrome. The exact threshold depends on attribute-mutation frequency and device class, so profile on representative hardware.

Do I need to null references if JavaScript is garbage collected?

Yes, when the reference lives longer than the chart. Module-scoped caches, closures captured by long-lived loops, and global maps keep their targets alive indefinitely. Nulling those references — or using WeakMap/WeakRef for caches — is what lets the collector actually reclaim large dataset arrays.