How to Take and Compare Heap Snapshots in Chrome Step by Step
Progressive UI lag, tab crashes, and GC pauses above 50 ms are the primary symptoms of unbounded object retention. The fastest path to resolution is deterministic heap diffing: capture two V8 snapshots around a suspected leak path, then let DevTools compute the delta. This guide walks through the exact workflow inside Interpreting Heap Snapshots for Memory Analysis — the parent cluster — which in turn sits inside the Browser DevTools & Performance Profiling Workflows reference.
Symptom-to-Fix Diagnostic Matrix
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
# Delta grows linearly across repeated identical operations |
Objects are not reachable by the mark-and-sweep algorithm because a live reference holds them | Trace the Retainers tree to the GC root; remove the stale reference | # Delta drops to 0; heap size stabilises |
Thousands of (string) or (array) entries in the diff |
Transient V8 JIT or string-interning allocations captured before GC swept them | Click Collect garbage 2–3× before Snapshot 2 (DevTools → Memory → trash-can icon) | Spurious entries disappear; only true retentions remain |
| Memory grows but the JS heap looks clean | Detached DOM nodes retained by orphaned event listeners or global caches | Filter the snapshot class list for Detached HTMLElement; expand Retainers |
Retained size of detached tree drops to 0 KB after fix |
Large Retained Size but small Shallow Size |
Object is a root for a large exclusively-owned sub-graph (array of closures, node list) | Sort by Retained Size descending; free the whole sub-graph, not just the root | Retained size freed equals the full sub-graph, often 1–20 MB |
Heap diff flooded with LayoutObject or V8Script entries |
Snapshot captured during active layout or network activity | Wait for network idle and main-thread idle (verify in DevTools → Performance panel) before capturing | Noise drops; true object-level delta is visible |
Root Cause Explanation
How V8 Represents the Heap at Snapshot Time
When you click Take snapshot in DevTools → Memory → Heap Snapshot, Chrome pauses JavaScript execution and serialises the entire V8 heap into a JSON graph of nodes (objects) and edges (references). Each node carries:
- Shallow size — the raw bytes the object itself occupies, excluding what it points to.
- Retained size — the bytes that would be freed if this node and every object reachable exclusively through it were collected. This is the number to optimise for.
V8’s generational heap divides objects into the new-space (short-lived, ~1–8 MB) and old-space (promoted survivors, up to the heap limit). A forced garbage collection before Snapshot 2 drains new-space, ensuring every object that appears in the diff genuinely survived promotion — a strong signal of true retention. Without that step, new-space survivors from JIT caches, string internment pools, and frame-level render buffers flood the diff with false positives.
The Comparison view performs a set difference between two snapshot node lists keyed on object addresses and constructor names. Its columns map directly to V8 internals:
# New— objects that exist in Snapshot 2 but not in Snapshot 1 (address or constructor appeared).# Deleted— objects present in Snapshot 1 but freed before Snapshot 2.# Delta—# Newminus# Deleted; the net change in live instances.Size Delta— net byte change; negative means memory was recovered.
A sustained positive # Delta across iterations of the same operation is the canonical signature of a closure memory leak — the closure (or the object it references) is being added each time and never released.
Inline SVG: Heap Snapshot Diff Workflow
Step-by-Step Fix
Step 1 — Environment Preparation and Baseline Capture
DevTools path: DevTools → Memory → Heap Snapshot → Take snapshot
- Open Chrome DevTools:
F12(Windows/Linux) orCmd + Option + I(macOS). - Navigate to the Memory panel.
- Select Heap snapshot from the profiling type radio group.
- Disable browser extensions (or use a clean profile) to eliminate instrumentation noise from non-application objects.
- Click the trash-can Collect garbage icon in the Memory panel toolbar once to drain the new-space of objects left over from page load.
- Click Take snapshot. Rename it
Snapshot 1 — Baselineby double-clicking the label in the left sidebar.
Expected output: The total heap size is displayed under the snapshot label (e.g., 12.4 MB). Record this figure; it is your memory budget baseline.
Verification checkpoint: The spinner finishes within 5–15 seconds for heaps below 200 MB. Larger heaps (serialising 50,000+ nodes) may take 30–60 seconds.
Step 2 — Trigger the Target Operation
- Return to the page and execute the exact user flow suspected of leaking memory — for example: open a modal ten times, navigate between routes five times, or dispatch an XHR loop.
- Confirm the UI has fully returned to its initial visual state (modal closed, route back at
/, loading spinner gone). - Wait 2–3 seconds to allow microtask queues,
requestAnimationFramecallbacks, and browser render threads to flush. Capturing during active layout or network I/O addsLayoutObjectandV8Scriptnoise to the diff.
Verification checkpoint: If you used the allocation timeline to identify the suspect path first, confirm the allocation spike you recorded corresponds to the operation you are about to diff.
Step 3 — Force GC and Capture the Post-Action Snapshot
DevTools path: DevTools → Memory → Collect garbage icon (trash can)
- Click the Collect garbage icon in the Memory panel toolbar.
- Repeat 2–3 times with a one-second pause between each click, until the live heap counter (shown in the Memory panel heading) stabilises.
- Click Take snapshot. Rename it
Snapshot 2 — Post-Action.
Expected output: Total size in Snapshot 2 is higher than Snapshot 1 by the amount genuinely retained (e.g., 14.1 MB vs 12.4 MB means 1.7 MB of net new retention).
Why forced GC matters: V8’s generational collector defers old-space collection until memory pressure crosses a threshold. Without forcing a full collection, new-space survivors from JIT caches, string interning pools, and render-frame buffers appear in the diff as false positives — often hundreds of (string) and (array) entries.
Step 4 — Run the Heap Diff in Comparison View
DevTools path: DevTools → Memory → select Snapshot 2 → Summary dropdown → Comparison
- In the left sidebar, click Snapshot 2.
- In the top-left dropdown (initially reads Summary), select Comparison. DevTools automatically selects Snapshot 1 as the reference.
- Read the delta columns:
# Delta— net change in live instances; persistent leaks show a positive value that does not drop after repeated GC.Size Delta— net byte change; sort descending to find the heaviest retained constructors.Retained Size— bytes freed if this object and its exclusive sub-graph were collected; the most actionable number.
- Sort by
# Deltadescending. Identify constructor names with positive# Deltathat match your application’s code (e.g.,MyComponent,SubscriptionManager,HTMLDivElement).
Verification checkpoint: If all deltas are near zero after multiple GC passes, there is no persistent retention from this operation. Investigate Size Delta next — a zero # Delta but large Size Delta means existing objects are growing (e.g., an unbounded array being appended to).
Step 5 — Filter and Trace the Retainer Chain
DevTools path: DevTools → Memory → Snapshot 2 → Comparison view → expand row → Retainers pane
- Click the arrow or triangle next to a suspect constructor to expand its instances.
- Click an individual instance. The Retainers pane at the bottom of the panel populates with the reference chain from this object up to the GC root.
- Walk the chain upward. Each line shows:
property nameonholding objectatdistance from root. Shorter distance means the object is closer to a live root. - Identify the first live reference that should not exist after the operation (e.g., a
clicklistener still registered ondocument.body, an entry in a globalMapkeyed by component ID that was never deleted). - Use the class filter box at the top of the snapshot panel to narrow the view: type
HTMLDivElementto find detached DOM nodes, or your component constructor name to find orphaned instances.
Verification checkpoint: The retainer chain should terminate at Window @<address> or (GC roots). If it terminates at a WeakMap or WeakRef, the object is not actually leaked — V8 will collect it under pressure.
Runnable Code Reference
Use-case: Generate a predictable closure retention for testing the workflow
Run this snippet in DevTools → Console to create five retained ~1 MB buffers attached to document.body. After running it, repeat Step 3 and Step 4 above. The diff should show Uint8Array and Function in the # Delta column.
// Intentional leak: buffers accumulate in leakArray, preventing GC
const leakArray = [];
function attachLeak() {
const largeBuffer = new Uint8Array(1024 * 1024); // ~1 MB allocation
// Handler closes over nothing, but stays alive via the leakArray reference
const handler = () => console.log('handler still alive');
document.body.addEventListener('click', handler); // registers on live DOM root
leakArray.push({ buffer: largeBuffer, ref: handler }); // strong reference chain
}
// Run attachLeak() five times in the console, then force GC and take Snapshot 2
// Expected: Snapshot 2 Comparison shows Uint8Array # Delta = +5, Size Delta ≈ +5.2 MB
Expected diff output:
- Constructor
Uint8Array:# Delta=+5,Size Delta=+5,242,880 bytes(~5 MB) - Constructor
Function:# Delta=+5(the handler closures) - Retainer chain:
Uint8Array←Object←Array←leakArray←Window
To fix: remove each event listener and clear the array before re-taking Snapshot 2. # Delta should return to 0.
// Fix: de-register listeners and release buffer references
leakArray.forEach(entry => {
document.body.removeEventListener('click', entry.ref); // breaks listener reference
// entry.buffer reference drops when leakArray is cleared below
});
leakArray.length = 0; // truncate in place; GC can now collect the Uint8Array instances
Use-case: Export snapshots for offline diffing or CI memory budgets
Save .heapsnapshot files for version-controlled regression tracking or Puppeteer-based CI checks.
// DevTools Protocol (CDP) — capture heap snapshot in Puppeteer / Node.js test
// Run with: node heap-budget-check.js
const puppeteer = require('puppeteer'); // requires puppeteer ≥ 21
const fs = require('fs');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
const client = await page.createCDPSession(); // open a raw CDP session
await page.goto('https://your-app.local/');
// Take Snapshot 1 (baseline)
let snap1 = '';
client.on('HeapProfiler.addHeapSnapshotChunk', ({ chunk }) => { snap1 += chunk; });
await client.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false });
fs.writeFileSync('snapshot1.heapsnapshot', snap1); // save for offline load
// Trigger the suspect operation
await page.click('#open-modal');
await page.click('#close-modal');
// Force GC via CDP, then take Snapshot 2
await client.send('HeapProfiler.collectGarbage');
let snap2 = '';
client.on('HeapProfiler.addHeapSnapshotChunk', ({ chunk }) => { snap2 += chunk; });
await client.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false });
fs.writeFileSync('snapshot2.heapsnapshot', snap2);
await browser.close();
// Load snapshot1.heapsnapshot and snapshot2.heapsnapshot into DevTools → Memory → Load
console.log('Snapshots saved. Load into DevTools → Memory → Load button to diff.');
})();
Verification and Regression Prevention
Confirming the fix worked
After applying the patch (removing the stale listener, clearing the cache, fixing the component teardown), repeat the full five-step workflow above. A successful resolution produces:
| Metric | Target | How to verify |
|---|---|---|
# Delta |
≈ 0 across 5+ repeated operations | Comparison view — all constructors show # Delta of 0 or ±1 (accounting for singleton jitter) |
Size Delta |
< 50 KB per operation cycle | Sort Comparison view by Size Delta descending; all rows should be below 50 KB |
| GC pause duration | < 10 ms | DevTools → Performance panel → record 60 s → inspect Long Tasks; GC events should not appear as red blocks |
| Heap growth slope | Flat line across 10 operations | Enable DevTools → Memory → Record allocation timeline and verify the blue bars do not grow in height over repeated cycles |
Guarding against recurrence
CI memory budget (Puppeteer / Node.js): Integrate the CDP snapshot script above into your test suite. After each modal open/close cycle, parse the serialised JSON and assert that the node count for your component constructor does not grow:
// Minimal heap-budget assertion — add to your existing test suite
// Fails if MyComponent instance count grows after 5 open/close cycles
const snap = JSON.parse(fs.readFileSync('snapshot2.heapsnapshot', 'utf8'));
// HeapSnapshot JSON: nodes encoded as flat typed array, strings in .strings[]
const strings = snap.strings;
const nodeFields = snap.snapshot.meta.node_fields; // ['type','name','id','self_size',...]
const nameIndex = nodeFields.indexOf('name');
const nodeFieldCount = nodeFields.length;
let componentCount = 0;
for (let i = 0; i < snap.nodes.length; i += nodeFieldCount) {
if (strings[snap.nodes[i + nameIndex]] === 'MyComponent') {
componentCount++; // count live MyComponent instances
}
}
console.assert(componentCount === 0, `Leaked ${componentCount} MyComponent instance(s)`);
ESLint rule: Add no-restricted-syntax (or a custom rule) to flag unbounded push on module-level arrays that could accumulate event listener references across re-renders.
Monitoring threshold: In production, sample performance.memory.usedJSHeapSize (Chrome only) once per minute. Alert if the 5-minute rolling average grows by more than 5 MB without a full page reload.
Frequently Asked Questions
Why does the heap diff show thousands of new objects after a simple UI interaction?
V8 optimises string concatenation, JIT compilation, and DOM reconciliation by creating short-lived new-space allocations. These should be swept before Snapshot 2 is taken. Click the trash-can Collect garbage icon 2–3 times, each time waiting for the heap counter to stabilise. If the delta persists after multiple GC passes, those objects are actively retained by a reference chain and represent a genuine leak.
How do I tell a memory leak apart from normal cache growth?
Leaks exhibit monotonic # Delta growth across repeated identical operations — each repetition adds more objects that are never freed. Normal caches (V8 code caches, browser image decode caches, application memoisation) grow to a plateau and then evict entries under memory pressure, so # Delta stabilises. Repeat the suspect operation 5–10 times and check whether # Delta grows linearly (leak) or flattens (cache).
Can I compare heap snapshots across different browser sessions?
Yes, if the application state and V8 version are identical between sessions. Export each snapshot with the Save (disk) icon next to the snapshot label. Load them back into DevTools → Memory via the Load button. Cross-session diffs are effective for tracking memory regression between production deployments — commit the .heapsnapshot files alongside your build artefacts for a durable audit trail.
Related
- Interpreting Heap Snapshots for Memory Analysis — parent reference covering dominator trees, retained-size calculations, and snapshot file anatomy
- Using Allocation Timelines to Track Object Creation — use before taking snapshots to confirm which operation allocates the objects you will diff
- Closure Memory Leaks in Modern JavaScript — identifies the closure patterns that most often produce persistent
# Deltain heap diffs - Detached DOM Nodes and Memory Retention — companion page for when the snapshot diff shows
Detached HTMLElemententries - Browser DevTools & Performance Profiling Workflows — grandparent pillar covering the full DevTools toolchain