How to check memory leak in a web page

One reading proves nothing. Repeat the interaction in rounds, force a collection before each reading, and record heap, node count and listener count every round. On the probe page five clean rounds held at 679 KB and 17 nodes, while the leaking build climbed to 1,934 KB and 40,117 nodes.

Why check this

Run this on any view a user opens and closes many times in one session: a modal, a drawer, a chart that rerenders on a filter. Leaks do not appear in a smoke test, which performs each action once. They appear in the third hour of a shift, and the report says the application got slow.

The failure it prevents is the one that survives release because nobody could reproduce it. An operator keeps one tab open all day and opens two hundred order panels, each close leaves its rendered rows behind, and by mid afternoon typing in a search field lags by a second.

Prerequisites

// leak-server.mjs   run: node leak-server.mjs   stop: Ctrl-C
import http from 'node:http';
const page = `<!doctype html><meta charset="utf-8"><title>leak probe</title>
<h1>leak probe</h1>
<p>mode comes from ?mode=clean | dom | js</p>
<script>
window.mode = new URLSearchParams(location.search).get('mode') || 'clean';
window.kept = [];
function interact() {              // one open-and-close of a 200 row panel
  const panel = document.createElement('div');
  for (let i = 0; i < 200; i++) {
    const row = document.createElement('div');
    row.textContent = 'row ' + i;
    row.addEventListener('click', () => { panel.dataset.last = i; });
    panel.appendChild(row);
  }
  document.body.appendChild(panel);
  panel.remove();
  if (window.mode === 'dom') window.kept.push(panel);        // keeps the subtree
  if (window.mode === 'js') window.kept.push(new Array(20000).fill('x').join(''));
}
<\/script>`;
http.createServer((req, res) => {
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
  res.end(page);
}).listen(8895, () => console.log('leak probe on http://localhost:8895/'));

Steps

  1. Step 1.

    Start the probe page. Stop it with Ctrl-C when you are finished.

    node leak-server.mjs
    
    leak probe on http://localhost:8895/

    Save the measuring script as trend.mjs. It runs 20 interactions per round for five rounds and forces a collection before every reading.

    // trend.mjs   run: node trend.mjs <mode> [nogc]
    import { launch } from 'puppeteer-core';
    const CHROME = 'C:/Program Files/Google/Chrome/Application/chrome.exe';
    const mode = process.argv[2] ?? 'clean';
    const gc = process.argv[3] !== 'nogc';
    const browser = await launch({ executablePath: CHROME, headless: true });
    const page = await browser.newPage();
    const cdp = await page.createCDPSession();
    await cdp.send('Performance.enable');
    await page.goto('http://localhost:8895/?mode=' + mode, { waitUntil: 'networkidle2' });
    
    console.log('mode', mode, gc ? 'with a forced collection before each reading' : 'with no forced collection');
    console.log('round   heap KB   nodes   listeners');
    for (let round = 0; round <= 5; round++) {
      if (round > 0) await page.evaluate(() => { for (let i = 0; i < 20; i++) interact(); });
      if (gc) await cdp.send('HeapProfiler.collectGarbage');
      const { metrics } = await cdp.send('Performance.getMetrics');
      const v = (n) => metrics.find((m) => m.name === n).value;
      console.log(String(round).padStart(5),
                  String(Math.round(v('JSHeapUsedSize') / 1024)).padStart(9),
                  String(v('Nodes')).padStart(7),
                  String(v('JSEventListeners')).padStart(11));
    }
    await browser.close();
    
  2. Step 2.

    Establish what no leak looks like. Run the clean mode first.

    node trend.mjs clean
    
    mode clean with a forced collection before each reading
    round   heap KB   nodes   listeners
      0       608      17           0
      1       679      17           0
      2       679      17           0
      3       679      17           0
      4       679      17           0
      5       679      17           0

    One hundred panels and twenty thousand rows were created and released in that run. The line that matters is round 1 to round 5: identical. Round 0 to round 1 rises by 71 KB, which is the cost of running the code the first time, not growth.

  3. Step 3.

    Run the mode that keeps the removed subtree.

    node trend.mjs dom
    
    mode dom with a forced collection before each reading
    round   heap KB   nodes   listeners
      0       608      17           0
      1       931    8037        4000
      2      1181   16057        8000
      3      1432   24077       12000
      4      1683   32097       16000
      5      1934   40117       20000

    Three columns rise together and the step between rounds is constant: close to 251 KB of heap, exactly 8,020 nodes and exactly 4,000 listeners. A constant step is the signature: something that grows and then settles is a cache filling, something with the same step per round has no ceiling.

  4. Step 4.

    Run the mode that keeps data instead of elements.

    node trend.mjs js
    
    mode js with a forced collection before each reading
    round   heap KB   nodes   listeners
      0       608      17           0
      1      1071      17           0
      2      1462      17           0
      3      1853      17           0
      4      2244      17           0
      5      2635      17           0

    The heap climbs 391 KB a round while the node count stays at 17 and no listener is ever retained. A rising heap with a flat node count is retained data: a growing array, a cache with no eviction, a closure holding a response. A rising node count is a retained tree, and that one is diagnosed on How to find detached DOM nodes.

  5. Step 5.

    Repeat the clean run with the forced collection removed. This is the most common false positive in the check.

    node trend.mjs clean nogc
    
    mode clean with no forced collection
    round   heap KB   nodes   listeners
      0      1050      29           0
      1      1491    8049        4000
      2      1477   16069        8000
      3      1807   24089       12000
      4      1923   32109       16000
      5      2253   40129       20000

    Same page, same interaction, no leak in it, and the table is nearly indistinguishable from step 3: 40,129 nodes and 20,000 listeners by round 5. Everything in those columns is garbage the browser had no reason to collect yet. The one tell is the heap column, which falls between round 1 and round 2, and a leak never falls.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | All three columns flat from round 1 | No leak in this interaction | Record the figures. Repeat with a longer round if the interaction is cheap. | | Constant rise in every column | A retained subtree, once per interaction | Find the reference with a heap snapshot, as on the detached nodes page. | | Heap rises, nodes flat | Retained data, not retained DOM | Look for arrays, caches and closures that outlive the view. | | Listeners rise in step with nodes | Every retained row carries its own handler | Bind one delegated listener on the container instead of one per row. | | A column that falls between rounds | The reading includes uncollected garbage | Force a collection before every reading and run it again. | | Round 0 to round 1 jump, then flat | Warm-up, not growth | Take the slope from round 1 onward. |

Common mistakes

Sign: A clean page reports 40,129 nodes and 20,000 listeners by round five.Cause: The readings were taken without HeapProfiler.collectGarbage. Step 5 is that run: the same page with no leak in it produces a table nearly identical to the leaking one in step 3. Collections happen when the browser decides, not when the test asks, so force one before every reading.
Sign: The first round grows and the report calls it a leak.Cause: Round 0 to round 1 on the clean page rises from 608 KB to 679 KB, because the interaction code runs for the first time and its compiled form, its strings and its caches are now allocated. That cost is paid once. Measure the slope from round 1 onward.
Sign: A monitor that watches DOM node count reports the page clean while memory grows.Cause: The js mode in step 4 adds 391 KB a round with the node count fixed at 17. A check built only on Nodes or only on JSHeapUsedSize sees half of the failures. Record heap, nodes and listeners in the same row of the same table.
Sign: The ticket says the heap reached 2,635 KB and nobody can tell whether that is bad.Cause: An absolute figure has no meaning across machines, Chrome versions and starting states. The finding in step 4 is 391 KB per round with a flat node count, and that sentence reproduces. Report the per-round delta and the number of rounds, never the total on its own.

What to check next

FAQ

How do I find JavaScript memory leaks with Chrome?

Repeat one interaction and its reverse in rounds, force a collection, and record heap, node and listener counts per round, as in steps 2 to 4. A leak is a constant rise. Then take a snapshot to name what is retained.

How do I take a heap snapshot in Chrome?

Open DevTools, select the Memory panel, select the Heap snapshot profiling type, choose the JavaScript VM instance and press Take snapshot. Over the DevTools protocol it is HeapProfiler.takeHeapSnapshot.

How do I compare two heap snapshots?

Take one before the interaction, perform the interaction and its reverse, then take a second and switch its view to Comparison against the first. Objects created and not released show up there. Chrome documents it in heap snapshots.

How many rounds are enough?

Five rounds of twenty interactions was enough for every mode here, because the step per round was constant from the first one. Add rounds when the numbers are close: a leak keeps its slope, a cache flattens out.

Verified

Verified by Maks VernyChrome 152.0.7977.76node 22.23.2puppeteer-core 25.10.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate12 minpublished updated Maks Verny