Skip to content

[BUG]: Memory Leak - Heatmap redraws continuously grow Chromium renderer memory via unique PNG data: URLs #8097

Description

@JeS24

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

  1. 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>
  1. Open the browser's Task Manager (Shift+Esc) and watch the tab's memory footprint (keep the tab/window in foreground).
  2. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2considered for next cyclebugsomething brokensize: 1

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions