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.
Step-by-Step Fix
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
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.
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.
Related
- Interpreting Heap Snapshots for Memory Analysis — the parent topic
- How to Take and Compare Heap Snapshots in Chrome, Step by Step — the two-snapshot basics this technique extends
- Heap Snapshot vs Allocation Timeline: When to Use Which — when a timeline answers the question faster
- Browser DevTools & Performance Profiling Workflows — the section overview