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# New minus # 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

Heap Snapshot Diff Workflow Five connected steps illustrating how to take and compare Chrome DevTools heap snapshots to identify memory leaks: capture baseline, trigger the leak path, force GC and capture post-action snapshot, open Comparison view, then walk the Retainers tree to the GC root. 1. Baseline Take Snapshot 1 (idle state) 2. Trigger Run suspect operation 3. Force GC Trash-can ×2–3 Take Snapshot 2 4. Compare Comparison view Sort # Delta ↓ 5. Retainers Walk chain to GC root DevTools → Memory e.g. open modal ×10 Eliminates new-space noise Snapshot 2 → Summary ▾ Window / Global root Pass criteria after fix # Delta ≈ 0 · Size Delta < 50 KB · GC pause < 10 ms

Step-by-Step Fix

Step 1 — Environment Preparation and Baseline Capture

DevTools path: DevTools → Memory → Heap Snapshot → Take snapshot

  1. Open Chrome DevTools: F12 (Windows/Linux) or Cmd + Option + I (macOS).
  2. Navigate to the Memory panel.
  3. Select Heap snapshot from the profiling type radio group.
  4. Disable browser extensions (or use a clean profile) to eliminate instrumentation noise from non-application objects.
  5. 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.
  6. Click Take snapshot. Rename it Snapshot 1 — Baseline by 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

  1. 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.
  2. Confirm the UI has fully returned to its initial visual state (modal closed, route back at /, loading spinner gone).
  3. Wait 2–3 seconds to allow microtask queues, requestAnimationFrame callbacks, and browser render threads to flush. Capturing during active layout or network I/O adds LayoutObject and V8Script noise 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)

  1. Click the Collect garbage icon in the Memory panel toolbar.
  2. Repeat 2–3 times with a one-second pause between each click, until the live heap counter (shown in the Memory panel heading) stabilises.
  3. 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

  1. In the left sidebar, click Snapshot 2.
  2. In the top-left dropdown (initially reads Summary), select Comparison. DevTools automatically selects Snapshot 1 as the reference.
  3. 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.
  4. Sort by # Delta descending. Identify constructor names with positive # Delta that 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

  1. Click the arrow or triangle next to a suspect constructor to expand its instances.
  2. 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.
  3. Walk the chain upward. Each line shows: property name on holding object at distance from root. Shorter distance means the object is closer to a live root.
  4. Identify the first live reference that should not exist after the operation (e.g., a click listener still registered on document.body, an entry in a global Map keyed by component ID that was never deleted).
  5. Use the class filter box at the top of the snapshot panel to narrow the view: type HTMLDivElement to 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: Uint8ArrayObjectArrayleakArrayWindow

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.