The Three-Snapshot Technique for Isolating Leaks

A two-snapshot comparison shows hundreds of “new” objects after your flow, and most of them are harmless first-run caches, lazily loaded modules and compiled code — this guide from Interpreting Heap Snapshots for Memory Analysis, in the Browser DevTools & Performance Profiling Workflows section, shows how a third snapshot filters that noise away and leaves only objects that genuinely accumulate.

Symptom Root Cause Immediate Action Measurable Impact
Comparison view lists 300+ constructors with positive delta First run initialises caches, modules, fonts and compiled code Warm up once, then take snapshots 1, 2 and 3 around repeated flows Candidate list shrinks to the handful that grow every repetition
A “leak” disappears the second time you check You were looking at one-time initialisation Only trust objects allocated between snapshots 1 and 2 that survive in snapshot 3 Eliminates false positives before anyone reads code
Leak only visible after many repetitions Per-flow growth is small relative to noise Repeat the flow N times between snapshots, not once Growth scales ×N and stands out from noise
Snapshot 3 still contains objects from the first flow Objects created in flow 1 were never released Filter snapshot 3 to “Objects allocated between Snapshot 1 and Snapshot 2” Every entry left is a confirmed survivor

Root Cause: Why Two Snapshots Are Not Enough

A heap comparison answers “what changed between A and B?”, but a lot changes the first time any feature runs for reasons that have nothing to do with leaking. Opening a modal for the first time loads its code-split chunk, compiles its functions, populates a template cache, instantiates a date formatter, creates a font face object and warms a router’s route table. All of those allocations appear as # New in a two-snapshot diff, and all of them are supposed to stay. Engineers who start from a two-snapshot diff spend hours reading retainer paths for objects that are working as intended.

The three-snapshot technique separates initialisation from accumulation using the time each object was allocated. Chrome’s heap profiler assigns every object an increasing ID, so each snapshot knows which objects were created after the previous one. The Summary view of a later snapshot has a filter dropdown — All objects, Objects allocated before Snapshot 1, Objects allocated between Snapshot 1 and Snapshot 2, and so on. The procedure is: warm the feature up once, take snapshot 1; repeat the flow, take snapshot 2; repeat the flow again, take snapshot 3. Now open snapshot 3 and filter to objects allocated between snapshot 1 and snapshot 2.

What is left is the set of objects created during the second run of the flow — after all one-time initialisation already happened — that are still alive after a third run. A correct flow creates objects and releases them when it finishes, so this set should be close to empty. Every object in it is a survivor that the next run did not reclaim: exactly the definition of a leak. It works with any constructor, including detached DOM nodes, closures and framework internals, and it pairs naturally with reading the Retainers panel once a survivor is selected.

The three-snapshot timeline A horizontal timeline shows a warm-up run followed by snapshot one, a first measured flow followed by snapshot two, and a second measured flow followed by snapshot three. Objects allocated in the band between snapshot one and two are grouped; those that are still present in snapshot three are marked as leaked survivors, while the rest were collected normally. Warm-up run caches, chunks, code S1 Flow ×N objects tagged "between S1 and S2" S2 Flow ×N again gives GC every chance S3 collected before S3 normal — ignored still alive in S3 survivors = your leak list Open S3 → filter "Objects allocated between Snapshot 1 and Snapshot 2"

Step-by-Step Fix

  1. Prepare a clean, repeatable flow. Use an extension-free profile, load the page, and pick a flow that should return the app to the same state — open and close a dialog, navigate to a route and back, add and remove an item. Verification: after the flow, the UI looks exactly as it did before.
  2. Warm up once, then take snapshot 1. Run the flow one time to trigger lazy loading and caches. Then in DevTools → Memory, select Heap snapshot and click Take snapshot. Verification: “Snapshot 1” appears in the sidebar.
  3. Run the flow N times, then take snapshot 2. Choose N between 3 and 10 so per-flow growth is multiplied. Verification: “Snapshot 2” appears; its total size is typically slightly larger than snapshot 1.
  4. Run the flow N times again, then take snapshot 3. This gives the collector a full additional cycle to reclaim anything from the previous runs. Verification: “Snapshot 3” appears.
  5. Filter snapshot 3 to survivors. Select snapshot 3, keep the Summary perspective, and change the filter dropdown from All objects to Objects allocated between Snapshot 1 and Snapshot 2. Sort by Retained Size. Verification: the list is short — often fewer than 20 constructors — and counts are multiples of N if the leak is per flow.
  6. Investigate survivors from the top. Click the largest survivor and read its Retainers to the first reference your code owns, then fix and repeat the whole procedure. Verification: after the fix, the filtered view of a new snapshot 3 is empty or contains only a handful of engine-internal entries.
Noise removed by the third snapshot Three bars show constructors needing investigation. A two-snapshot comparison flags 312 constructors with positive delta. The three-snapshot survivor filter flags 14. After fixing the leaking subscription, the survivor filter flags 2 engine-internal groups. Constructors to investigate (same flow, N = 5) Two-snapshot diff 312 with positive delta Three-snapshot survivors 14 — all real survivors Survivors after the fix 2 — (system), (compiled code)

Command and Code Reference

Use case: automate the three snapshots with Puppeteer so the procedure is identical every time. The script writes three .heapsnapshot files you can load into DevTools, with the flow repeated the same N times.

// three-snapshots.mjs — node three-snapshots.mjs
import puppeteer from 'puppeteer';
import { writeFileSync } from 'node:fs';

const N = 5;
const browser = await puppeteer.launch();
const page = await browser.newPage();
const cdp = await page.createCDPSession();
await page.goto('http://localhost:5173/', { waitUntil: 'networkidle0' });

// The flow must return the UI to its starting state
async function flow() {
  await page.click('#open-settings');
  await page.waitForSelector('.settings-dialog');
  await page.click('.settings-dialog .close');
  await page.waitForSelector('.settings-dialog', { hidden: true });
}

async function snapshot(name) {
  const chunks = [];
  const onChunk = ({ chunk }) => chunks.push(chunk);
  cdp.on('HeapProfiler.addHeapSnapshotChunk', onChunk);
  await cdp.send('HeapProfiler.collectGarbage');               // settle first
  await cdp.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false });
  cdp.off('HeapProfiler.addHeapSnapshotChunk', onChunk);
  writeFileSync(`${name}.heapsnapshot`, chunks.join(''));
}

await flow();                                   // warm-up: lazy chunks, caches
await snapshot('s1');
for (let i = 0; i < N; i++) await flow();
await snapshot('s2');
for (let i = 0; i < N; i++) await flow();
await snapshot('s3');
await browser.close();
// Load all three into DevTools → Memory (right-click sidebar → Load) and filter s3

Use case: a typical survivor and its fix. The survivors list in such runs often contains a subscription callback registered on every open and never removed.

// Leaky: each open subscribes; close never unsubscribes
function openSettings(store) {
  const dialog = renderDialog();
  store.subscribe(() => dialog.update(store.getState())); // survives every close
}

// Fixed: keep the unsubscribe function and call it on close
function openSettingsFixed(store) {
  const dialog = renderDialog();
  const unsubscribe = store.subscribe(() => dialog.update(store.getState()));
  dialog.onClose(() => {
    unsubscribe();   // releases the closure, and with it the dialog subtree
    dialog.destroy();
  });
}

Verification and Regression Prevention

The procedure itself is the verification: run it again after your fix, with the same N, and the filtered snapshot 3 view should contain nothing that scales with N. If you still see a count of exactly 5 or 10 for some constructor, there is a second leak on the same flow. Engine groups such as (compiled code) or (system) may show a couple of survivors from optimisation — accept those if their count does not scale with N.

Three practical details decide whether the technique gives a clean answer. First, the flow must be genuinely idempotent: if closing a dialog leaves a toast notification in a list, or a navigation adds an entry to a legitimately bounded history, those objects will show up as survivors even though nothing is wrong. Either make the flow return exactly to the starting state or learn to recognise those expected survivors by their count. Second, background activity must be quiet. Polling timers, analytics beacons and websocket heartbeats allocate between your snapshots and can leave objects alive at the moment of capture; pause them with a debug flag, or run the flow against a local mock server. Third, keep N identical for both halves of the procedure. If snapshot 2 follows five repetitions and snapshot 3 follows two, you lose the tidy “count equals N” signature that makes per-flow leaks easy to spot.

When the filtered view is long, sort by # New rather than by size first. Leaks tend to show as a family of constructors that all have the same count — for instance 5 Subscription objects, 5 closures, 5 HTMLDivElement roots and 5 context objects — because one retained root keeps a whole subtree alive. Fix the root of that family and the rest disappear together.

The three-snapshot pattern is also how most automated tools work under the hood. Memlab, for example, takes a baseline, target and final snapshot and reports objects allocated by the target action that remain after the final one; wiring your flow into it, as described in finding leaks with Memlab scenarios, turns this manual technique into a CI check. Keep N and the flow definition in version control so every run is comparable.

Interpreting survivors after the fix Rerun the three-snapshot procedure with the same N after the fix. If nothing in the filtered snapshot 3 scales with N, the leak is gone. A constructor with a count of exactly N points to a second leak on the same flow. A couple of survivors in compiled code or system groups that do not scale with N are optimisation noise. Filtered snapshot 3, same N Fixed: the flow retains nothing per run nothing scales with N A second leak on the same flow; trace that constructor some count equals N Accept if the count does not scale with N a few engine survivors

Frequently Asked Questions

Why filter snapshot 3 instead of comparing snapshots 2 and 3?

Comparing 2 and 3 still shows objects created during the last run that have not yet been collected, plus any late initialisation. Filtering snapshot 3 to objects allocated between 1 and 2 selects objects that had a whole additional run to be released and were not, which is a much stronger signal of retention.

How many repetitions should I use between snapshots?

Enough that a per-flow leak stands out: three to ten is typical. Larger N makes small leaks visible and makes the counts easy to recognise — a constructor with exactly 5 survivors after 5 repetitions is almost certainly leaking once per flow.

Does taking a snapshot change what I am measuring?

Each snapshot triggers a full garbage collection first, so only reachable objects are recorded. That is desirable here, because it means survivors are genuinely retained. It does mean snapshots affect timing, so do not combine this procedure with a performance recording.