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.
Step-by-Step Fix
- Count instances. In the Console, check
Object.keys(Chart.instances).lengthfor Chart.js; for ECharts, count elements for whichecharts.getInstanceByDom(el)returns an instance. Verification: counts after navigation equal the number of charts on screen, or they grow. - 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.
- Update in place. For Chart.js, assign new
chart.data(or mutate datasets) and callchart.update(); for ECharts, callsetOption(newOption, { replaceMerge: ['series'] })ornotMerge: truewhen replacing series entirely. Verification: the same instance renders new data. - Destroy on unmount. Call
chart.destroy()(Chart.js) orchart.dispose()(ECharts) in the component’s cleanup. Verification: the instance count drops when the component unmounts. - Remove your own listeners. Remove window resize listeners you added, or use a
ResizeObserveron the container and disconnect it in cleanup. Verification:getEventListeners(window).resize(Chrome Console) does not grow. - 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.
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.
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.
Related
- Third-Party Library Memory Leaks — the parent topic
- Map Library Instance Memory in Leaflet and Mapbox — the same lifecycle for maps
- Canvas and ImageBitmap Memory in Long-Running Apps — canvas backing-store costs
- Framework-Specific Memory Optimization — the section overview