Destroying Chart Instances in Chart.js and ECharts

A dashboard’s memory rises every time the user switches date ranges, Chart.js throws “Canvas is already in use. Chart with ID ‘3’ must be destroyed before the canvas can be reused”, or ECharts keeps firing resize handlers for charts that are no longer on screen. This guide from Third-Party Library Memory Leaks, in Framework-Specific Memory Optimization, explains how both libraries track chart instances, what destroy() and dispose() release, and how to update charts in place so you rarely need to re-create them at all.

Symptom Root Cause Immediate Action Measurable Impact
“Canvas is already in use” error in Chart.js Previous chart on the canvas never destroyed Chart.getChart(canvas)?.destroy() before creating, or reuse the instance No duplicate instances per canvas
Memory grows each time data or options change Chart re-created on every change Update data and call chart.update() / setOption() One instance per chart; far less allocation
Detached canvases in snapshots after navigation Component unmounted without destroy()/dispose() Call teardown in the component cleanup Canvas and chart state collectable
ECharts resize handlers pile up App-added window resize listeners never removed Remove the listener (or use a ResizeObserver you disconnect) on teardown Constant listener count
Large series keep old data after updates Merging options keeps previous series Use notMerge or replaceMerge for series you replace Old data released

Root Cause: Global Instance Registries and Listeners

Chart.js keeps every chart in a global registry (Chart.instances) keyed by an ID, and associates each canvas with its chart so it can refuse to create a second chart on the same canvas — which is exactly what the “Canvas is already in use” error reports. A chart also attaches responsive-resize handling (listeners or observers on the container), animation state and plugin state. Until you call chart.destroy(), the registry keeps the chart, and the chart keeps its canvas, datasets, options, plugins and any closures in callbacks such as tooltip formatters. Removing the canvas from the DOM leaves all of that reachable: a detached canvas plus a live chart object, retained through the registry — the pattern of third-party widgets leaving detached DOM behind.

ECharts also keeps instances in an internal map keyed by the DOM element (echarts.getInstanceByDom(el) finds them). echarts.init(el) on an element that already has an instance warns and returns the existing one in current versions, but code that clears the element’s contents and calls init again, or creates new elements per render, accumulates instances. ECharts does not resize itself automatically when the window changes, so applications commonly add window.addEventListener('resize', () => chart.resize()); that listener, if never removed, keeps the chart alive forever even after dispose(). instance.dispose() (or echarts.dispose(el)) releases the instance’s rendering resources, zrender state, event handlers it registered and its registry entry — but not listeners you added.

Both libraries encourage updating instead of re-creating. Chart.js charts can take new data and options and re-render with chart.update(); ECharts takes new options through setOption(option, { notMerge, replaceMerge }). Re-creating a chart on every data change allocates a new instance, new canvas contexts and new internal structures each time, and relies on flawless teardown every time to avoid leaks. Updating in place allocates only what changed.

Where chart instances are retained Chart.js stores charts in the global Chart.instances registry; a chart that was never destroyed retains its canvas, datasets and options even after the canvas leaves the DOM. ECharts stores instances in a map keyed by DOM element; dispose removes the entry. An application-added window resize listener that calls chart.resize keeps the ECharts instance reachable even after dispose unless the listener is removed. Chart.instances global registry chart #3 (not destroyed) canvas, datasets, options detached <canvas> removed by the framework window listeners added by your app () => chart.resize() closure over ECharts instance ECharts instance disposed but still reachable

Step-by-Step Fix

  1. Count instances. In the Console, check Object.keys(Chart.instances).length for Chart.js; for ECharts, count elements for which echarts.getInstanceByDom(el) returns an instance. Verification: counts after navigation equal the number of charts on screen, or they grow.
  2. Create once per mount. Ensure the chart is created only in the component’s mount phase, not in render functions or effects that run on every data change. Verification: changing filters no longer increments the instance count.
  3. Update in place. For Chart.js, assign new chart.data (or mutate datasets) and call chart.update(); for ECharts, call setOption(newOption, { replaceMerge: ['series'] }) or notMerge: true when replacing series entirely. Verification: the same instance renders new data.
  4. Destroy on unmount. Call chart.destroy() (Chart.js) or chart.dispose() (ECharts) in the component’s cleanup. Verification: the instance count drops when the component unmounts.
  5. Remove your own listeners. Remove window resize listeners you added, or use a ResizeObserver on the container and disconnect it in cleanup. Verification: getEventListeners(window).resize (Chrome Console) does not grow.
  6. Re-run the navigation cycle. Switch routes or date ranges twenty times and take a heap snapshot. Verification: no detached canvases and no growth in chart objects.
Instances across 30 filter changes (4 charts on screen) Re-creating four charts on every filter change without destroy leaves 124 live instances after thirty changes. Re-creating with destroy keeps 4 live instances but allocates a new set each time. Updating in place keeps the same 4 instances throughout with minimal allocation. 124 0 re-create without destroy() update in place (or destroy before re-create): 4 filter changes (0 → 30)

Command and Code Reference

Use case: Chart.js created once, updated in place, destroyed on unmount (React).

import { Chart } from 'chart.js/auto';

function SalesChart({ labels, values }) {
  const canvasRef = useRef(null);
  const chartRef = useRef(null);

  useEffect(() => {
    chartRef.current = new Chart(canvasRef.current, {
      type: 'line',
      data: { labels: [], datasets: [{ label: 'Sales', data: [] }] },
      options: { animation: false, responsive: true },
    });
    return () => chartRef.current.destroy();       // registry entry, listeners, canvas state
  }, []);

  useEffect(() => {
    const chart = chartRef.current;
    chart.data.labels = labels;                    // replace data, keep the instance
    chart.data.datasets[0].data = values;
    chart.update('none');                          // re-render without animation
  }, [labels, values]);

  return <canvas ref={canvasRef} />;
}

Use case: ECharts with a container observer instead of window listeners.

import * as echarts from 'echarts';

export function mountEChart(el, option) {
  const chart = echarts.init(el);
  chart.setOption(option);
  const ro = new ResizeObserver(() => chart.resize()); // follows the container, not the window
  ro.observe(el);
  return {
    update(next) {
      chart.setOption(next, { replaceMerge: ['series'] }); // drop old series data
    },
    destroy() {
      ro.disconnect();                                  // remove our observer first
      chart.dispose();                                  // then the instance itself
    },
  };
}

Use case: defensive creation on a reused canvas.

// If legacy code may create a chart on a canvas that still has one
Chart.getChart(canvas)?.destroy();
const chart = new Chart(canvas, config);

Verification and Regression Prevention

Verify with instance counts and snapshots: after repeated navigation and filter changes, Chart.instances (or the number of ECharts instances) equals the charts on screen, no detached <canvas> elements remain, and resize listener counts are constant. For long-running dashboards, also check that updating data releases previous datasets: snapshots after thirty updates should hold one dataset per chart, not thirty.

Wrap chart creation in a single integration module per library (as in the parent topic’s wrapper pattern) so every component uses the same create/update/destroy path, and add an end-to-end test that navigates away from chart pages repeatedly and asserts on instance counts exposed in development. When charts render inside effects driven by signals or reactive state, pair this with framework-specific teardown such as Angular signals effect cleanup.

Chart instances across dashboard navigations Across repeated dashboard navigation and filter changes, charts created without destroy in Chart.js or dispose in ECharts accumulate instances and detached canvases. With teardown on unmount and chart.update for new data, the instance count equals the charts on screen. instances navigations and filter changes new chart per change, no destroy() destroy()/dispose() + update() Chart.instances in Chart.js, or the ECharts instance count, should equal visible charts.

Edge Cases and Gotchas

Callbacks in options capture state

Tooltip formatters, label callbacks and click handlers in options are closures. If they capture large component state, the chart retains it until destroyed or until the options are replaced. Keep callbacks small and read current data through refs or arguments.

Plugins with global registration

Chart.js plugins registered globally (Chart.register) apply to all charts and live for the session. Register them once at startup, not per component, or you will register duplicates.

Large datasets and decimation

Rendering hundreds of thousands of points keeps all of them in chart data. Use Chart.js decimation or ECharts’ large-data modes, and consider downsampling before handing data to the chart.

Canvas size and device pixel ratio

A chart’s canvas backing store is width × height × devicePixelRatio² × 4 bytes. Many large charts on a high-DPI screen add up to tens of megabytes before any data; lazy-render charts that are off-screen.

Frequently Asked Questions

How do I fix “Canvas is already in use” in Chart.js?

Destroy the existing chart on that canvas before creating a new one — Chart.getChart(canvas)?.destroy() — or better, keep a reference to the chart and update it instead of creating another. The error means a previous chart was never destroyed.

Does removing the canvas from the DOM free the chart?

No. Chart.js keeps charts in a global registry, and ECharts keeps instances in an internal map, until you call destroy() or dispose(). Removing the element leaves the instance and its detached canvas in memory.

Should I re-create charts when data changes?

Prefer updating in place: assign new data and call chart.update() in Chart.js, or call setOption with appropriate merge options in ECharts. Re-creation allocates a whole new instance each time and depends on perfect teardown.

Does ECharts dispose() remove my resize listener?

No. dispose() releases what ECharts created. Listeners your application added — commonly window resize handlers calling chart.resize() — must be removed by your code, or they keep the instance reachable.

Do charts inside hidden tabs need to be destroyed?

Not necessarily — a chart in a hidden tab that the user will return to can stay alive, as long as the number of such charts is bounded. Pause live updates while hidden, and destroy charts in tabs that are closed or unlikely to be revisited.

How can I find leaked chart instances?

Check Chart.instances in the Console or count ECharts instances by DOM, then take a heap snapshot filtered by Detached to find orphaned canvases; their retainers lead to the registry or listener keeping them alive.