Description
Every heatmap redraw sets the trace's SVG <image> to a fresh canvas.toDataURL('image/png') (src/traces/heatmap/plot.js#L369). In Chromium, these distinct data-URL image resources remain retained in the document's image/resource cache for the lifetime of the page. A heatmap that is updated repeatedly (e.g., live data, Plotly.react in a loop) therefore grows the renderer's native memory by approximately one image resource per update. The memory is not reclaimed while the page remains alive. The JS heap stays flat, so heap snapshots don't show the growth.
We hit this in a desktop app (Edge WebView2) that plots live measurements. A heatmap, refreshing about once a second, took the renderer from 1.2 GB to 13.5 GB in 3 h 13 min, after which it was killed with RenderProcessExited, Reason=OutOfMemory.
Screenshots/Video
No video, but here is the memory of the example below, measured over 10 minutes with plotly.js 4.1.1 (un-minified) in Chromium 153.0.8010.12 on Windows 11. A full GC was forced before each sample (--js-flags=--expose-gc, gc() twice); "renderer" is the renderer process's private memory.
| Time |
Updates |
Renderer, as is |
Renderer, with the workaround in Notes |
JS heap (both) |
| 0 min |
1 |
162 MB |
164 MB |
32 MB |
| 1 min |
~160 |
391 MB |
388 MB |
32 MB |
| 2 min |
~300 |
389 MB |
388 MB |
32 MB |
| 4 min |
~550 |
660 MB |
549 MB |
32 MB |
| 6 min |
~790 |
899 MB |
651 MB |
32 MB |
| 8 min |
~1000 |
1090 MB |
705 MB |
32 MB |
| 10 min |
~1300 |
1123 MB |
483 MB |
32 MB |
As is, the renderer gains about 0.7 MB per update, roughly 100 MB per minute at ~2.2 updates/s. With the workaround, memory remains bounded and can fall back substantially after reclamation. 4.0.0 behaves the same (139 -> 1310 MB over the same 10 minutes).
Steps to reproduce
- Save this as an HTML file and open it in Chrome or Edge:
<!doctype html>
<meta charset="utf-8">
<div id="plot" style="width:900px;height:700px"></div>
<script src="https://cdn.plot.ly/plotly-4.1.1.js"></script>
<script>
// A "live" 1000x1000 heatmap: one more row is filled on every update.
const N = 1000;
let frame = 0;
function nextZ() {
frame++;
const z = [];
for (let r = 0; r < N; r++) {
const row = new Array(N);
for (let c = 0; c < N; c++) {
row[c] =
r <= frame % N
? Math.sin(r / 30 + c / 50 + frame / 10)
: NaN;
}
z.push(row);
}
return z;
}
async function update() {
await Plotly.react("plot", [{ type: "heatmap", z: nextZ() }]);
setTimeout(update, 250);
}
update();
</script>
- Open the browser's Task Manager (Shift+Esc) and watch the tab's memory footprint (keep the tab/window in foreground).
- It climbs by roughly 100 MB a minute and does not come back down, while the JS heap (DevTools > Memory) stays at ~30 MB.
Notes
It's the data URLs, not Plotly's data handling. I repeated the test without Plotly, using one SVG <image> whose href is set to a new 1580×1580 PNG on every update:
- A data URL each time: renderer 186 → 1220 MB over 600 updates.
- The same PNG as a blob URL, revoking the previous one: flat at ~500 MB.
- Replacing the
<image> element instead of updating it: still grows.
- Swapping the data URL for a blob URL one animation frame later: still grows, because the data URL is loaded as soon as it is set.
Real-world numbers. Edge WebView2 154.0.4258.37 with a 1580×1580 heatmap (2.5M points) updated about once a second. The renderer grew ~3.8 GB/h and crashed after 3 h 13 min at 13.5 GB, while the page's JS heap stayed between 0.45 and 1 GB.
Suggested fix
In heatmap/plot.js, use a blob URL for on-screen rendering and revoke the previous one. Keep the data URL for toImage, which serializes the SVG, where a blob: URL would not survive:
// Instead of: 'xlink:href': canvas.toDataURL('image/png')
if (gd._context._exportedPlot) {
image3.attr('xlink:href', canvas.toDataURL('image/png'));
} else {
canvas.toBlob(function(blob) {
var node = image3.node();
if (!node || !blob) return;
var url = URL.createObjectURL(blob);
if (node._blobUrl) URL.revokeObjectURL(node._blobUrl);
node._blobUrl = url;
node.setAttributeNS(xmlnsNamespaces.xlink, 'xlink:href', url);
});
}
The last blob URL should also be revoked when the image is removed or the plot is purged. toBlob is asynchronous, so the previous image stays on screen until the new one is ready, which looks the same as today. A synchronous alternative is to decode the data URL into a Blob and use URL.createObjectURL, which is what our workaround does.
I haven't built or tested this patch inside plotly.js, but it works for us in our app.
traces/image/plot.js builds its href the same way and probably has the same problem; I haven't measured it.
Workaround
Swap each new data URL for a blob URL from a MutationObserver on the plot. It must happen in the same task as Plotly's update; in my testing, waiting until the next animation frame is already too late:
const blobs = new WeakMap();
new MutationObserver((records) => {
for (const { target, attributeName, attributeNamespace } of records) {
if (attributeName !== "href" || target.localName !== "image") continue;
const href = target.getAttributeNS(attributeNamespace, "href");
if (!href?.startsWith("data:image/png;base64,")) continue;
const bin = atob(href.slice(22));
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const url = URL.createObjectURL(new Blob([bytes], { type: "image/png" }));
target.setAttributeNS(attributeNamespace, attributeNamespace ? "xlink:href" : "href", url);
if (blobs.has(target)) URL.revokeObjectURL(blobs.get(target));
blobs.set(target, url);
}
// No attributeFilter: it never matches namespaced attributes such as xlink:href.
}).observe(document.getElementById("plot"), { attributes: true, subtree: true });
Environment
- plotly.js 4.1.1 and 4.0.0
- Chromium 153.0.8010.12 and Edge WebView2 154.0.4258.37
- Windows 11 (build 26200).
Not tested in Firefox or Safari.
Description
Every heatmap redraw sets the trace's SVG
<image>to a freshcanvas.toDataURL('image/png')(src/traces/heatmap/plot.js#L369). In Chromium, these distinct data-URL image resources remain retained in the document's image/resource cache for the lifetime of the page. A heatmap that is updated repeatedly (e.g., live data,Plotly.reactin a loop) therefore grows the renderer's native memory by approximately one image resource per update. The memory is not reclaimed while the page remains alive. The JS heap stays flat, so heap snapshots don't show the growth.We hit this in a desktop app (Edge WebView2) that plots live measurements. A heatmap, refreshing about once a second, took the renderer from 1.2 GB to 13.5 GB in 3 h 13 min, after which it was killed with
RenderProcessExited, Reason=OutOfMemory.Screenshots/Video
No video, but here is the memory of the example below, measured over 10 minutes with plotly.js 4.1.1 (un-minified) in Chromium 153.0.8010.12 on Windows 11. A full GC was forced before each sample (
--js-flags=--expose-gc,gc()twice); "renderer" is the renderer process's private memory.As is, the renderer gains about 0.7 MB per update, roughly 100 MB per minute at ~2.2 updates/s. With the workaround, memory remains bounded and can fall back substantially after reclamation. 4.0.0 behaves the same (139 -> 1310 MB over the same 10 minutes).
Steps to reproduce
Notes
It's the data URLs, not Plotly's data handling. I repeated the test without Plotly, using one SVG
<image>whosehrefis set to a new 1580×1580 PNG on every update:<image>element instead of updating it: still grows.Real-world numbers. Edge WebView2 154.0.4258.37 with a 1580×1580 heatmap (2.5M points) updated about once a second. The renderer grew ~3.8 GB/h and crashed after 3 h 13 min at 13.5 GB, while the page's JS heap stayed between 0.45 and 1 GB.
Suggested fix
In
heatmap/plot.js, use a blob URL for on-screen rendering and revoke the previous one. Keep the data URL fortoImage, which serializes the SVG, where ablob:URL would not survive:The last blob URL should also be revoked when the image is removed or the plot is purged.
toBlobis asynchronous, so the previous image stays on screen until the new one is ready, which looks the same as today. A synchronous alternative is to decode the data URL into aBloband useURL.createObjectURL, which is what our workaround does.I haven't built or tested this patch inside plotly.js, but it works for us in our app.
traces/image/plot.jsbuilds itshrefthe same way and probably has the same problem; I haven't measured it.Workaround
Swap each new data URL for a blob URL from a
MutationObserveron the plot. It must happen in the same task as Plotly's update; in my testing, waiting until the next animation frame is already too late:Environment
Not tested in Firefox or Safari.