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
- Chrome 152.0.7977.76 on Windows. The figures below are one capture on one machine, taken on 2026-09-11. Heap numbers move between machines and Chrome versions, so read the shape of a column, not its absolute value.
- Node 22 or later and
puppeteer-core, installed withnpm i puppeteer-core. It drives the Chrome you already have. See the puppeteer API. - The Chrome path in each script is the Windows default. On macOS it is
/Applications/Google Chrome.app/Contents/MacOS/Google Chrome. - Save this as
leak-server.mjs. One interaction renders a 200 row panel and removes it. Themodeparameter decides what survives:cleankeeps nothing,domkeeps the removed subtree,jskeeps a string.
// 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
- Step 1.
Start the probe page. Stop it with Ctrl-C when you are finished.
node leak-server.mjsleak 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(); - Step 2.
Establish what no leak looks like. Run the clean mode first.
node trend.mjs cleanmode 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 0One 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.
- Step 3.
Run the mode that keeps the removed subtree.
node trend.mjs dommode 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 20000Three 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.
- Step 4.
Run the mode that keeps data instead of elements.
node trend.mjs jsmode 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 0The 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.
- 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 nogcmode 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 20000Same 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
What to check next
- How to find detached DOM nodes: the next step when the node column rises, and how to name what holds the tree.
- How to check DOM size of a page: the counters used here, read once on a page at rest.
- How to check console errors on a website: a teardown that throws leaves the view behind, and the exception explains it.
- How to check which third party scripts a page loads: when the growth survives disabling your own code, the remaining suspects are on this list.
- How to check localstorage size: storage that grows per interaction produces the same bug report.
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.
Related on this site
intermediate12 minpublished updated Maks Verny