React 18 - Chrome Memory Leak When Mounting Many Divs

React version: 18.1.0

Summary
React seems to cause Chrome to leak core Blink (renderer) memory when a large number of divs are mounted. Significantly more memory is leaked than when an equivalent script is run using vanilla JS.

In the following reproduction, 100k divs are mounted at a time. We loop through 50 "pages", each with 100k divs. We then render a blank page (or multiple pages with a single div, to ensure that React isn't holding onto the prior tree).

React Version:
400 MB of total "Memory Footprint" is retained. Note that the JS Memory usage is only 40MB. (Also note that the browser's total memory usage peaks significantly higher while these massive pages are loaded.)
image
(Screenshot from Chrome's "Task Manager")

In another run, 300 MB of total "Memory Footprint" is retained. Note that the live JS Memory usage is only 15MB.
image

Pure JS Version:
160 MB of total "Memory Footprint" is retained. Note that the JS Memory usage is only 2MB.
image

The browser is thus retaining an extra ~200MB (~300MB / 400MB vs ~150 MB) of memory when React is used.

Context:
I've dug through the JS Memory Heap dumps for both versions, but can't find a clear explanation for why Chrome is holding onto more memory here.

The issue still occurs when les divs are mounted, but mounting more divs makes the pattern easier to see.

I believe that this issue is the root-cause of a real-world issue I've been having in another application. In the real application we see an even bigger leak of > 1GB. In the following screenshot 1.5GB of total "Memory Footprint" is retained, while only 400MB of JS Memory is used.
image

Hypothesis:
I'm in contact with a Chrome-team developer who ran a separate reproduction script for our real-world application, and they hypothesized that Chrome was allocating a large amount of styles here. However, the issue still occurs when all CSS is removed. Is it possible that React is causing extra styles to be attached to these divs somehow? (Not sure if this is a red-herring or not. This might be a separate issue with our application.)

Quote from someone on the Chrome team:

I ran it through chrome://tracing and it looks like there are a lot of Blink (renderer) objects being allocated on PartitionAlloc, which is the allocator used for non-GC'd objects within the renderer. These tend to be performance-sensitive classes like strings, vectors, DOM nodes, style data, etc. The memory dump lists the bulk of this memory as (not web_cache, font_caches, site_storage, or parkable_strings).
I observed several OOM crashes while doing this, and the traces uploaded to Google's internal crash database all had StyleInheritedVariables::Copy() which doesn't necessarily mean that there's a leak there, but suggests that the app is causing a lot of copies of this object to be made. This object is allocated on PartitionAlloc, so that's a bit suspicious. I believe this is also not reflected in the JS heap size, so that's consistent with your observations. This object is used for the CSS Properties and Values API, in particular for storing custom properties that allow inheritance. Below is the relevant portion of the call stack. You can look up the code on source.chromium.org if you want. I took a quick look but all I can tell is that it probably has something to do with setting, changing, inheriting, or registering a CSS variable. I don't know if merely invoking var() causes the copy to be made.
So as a first step, I'd remove any uses of inherited CSS custom properties and see if that changes anything.

Steps To Reproduce

Note that you must give time for Chrome's GC to kick-in after the test stops. The test will take a few minutes to run.

Warning: These load heavy pages

React:


function App() {
  const [i, setI] = useState(0);

  // Re-render this component every 100ms
  useEffect(() => {
    setInterval(() => {
      setI((curI) => (curI += 1));
    }, 100);
  }, []);

  // 50 times, render a large list of 100k divs
  // We don't construct this large array on the initial run so that our `useEffect` closure 
  // doesn't accidentally include a large `c` array.
  let c = [];
  if (i > 2 && i < 50) {
    for (let j = 0; j < 100 * 1000; j++) {
      c.push(
        <div>
          {j} - {Math.random()}
        </div>
      );
    }
  }

  return (
    <div className="App">
      Memory Test - {i}
      <br />
      {c}
    </div>
  );
}

Pure JS:

<title>100k Divs Vertically, then Clear</title>

<div id="root"></div>

<script>
  const root = document.getElementById("root");

  let i = 0;
  const int = setInterval(() => {
    console.log(i);

    for (const child of [...root.childNodes].reverse()) {
      child.remove();
    }

    for (let j = 0; j < 100 * 1000; j += 1) {
      const d = document.createElement("div");
      d.innerText = "j" + j + "-" + Math.random();
      root.appendChild(d);
    }

    i += 1;

    if (i == 100) {
      clearInterval(int);
      root.remove();
    }
  }, 100);
</script>