Finding Leaks with Memlab Scenarios
Manual heap snapshot diffing works, but nobody repeats it before every release, and leaks slip back in. This guide from Automated Memory Leak Detection in CI, in the Browser DevTools & Performance Profiling Workflows section, shows how Memlab automates the snapshot-and-diff procedure with a small scenario file, how to read the retainer traces it prints, and how to run it on every pull request.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
| Leaks reappear after being fixed | No automated check on the leaking flow | Encode the flow as a Memlab scenario | Regression fails CI instead of reaching users |
| Manual snapshot diffs take an hour each | Snapshots, filters and retainer reading done by hand | Let Memlab take baseline/target/final snapshots | Minutes per flow, fully repeatable |
| Memlab reports hundreds of “leaks” | Caches and singletons created by the action are expected to persist | Add a leakFilter or tighten the back step |
Report shrinks to real leaks |
| Trace is long and hard to read | Default output includes framework internals | Focus on the first frames from your code; use --trace-object-size-above |
Faster identification of the owner |
| Flaky results between runs | Timers, animations and network timing | Wait for stable UI in action/back; mock slow APIs |
Consistent reports across runs |
Root Cause: A Leak Test Is Three Snapshots and a Filter
Memlab, an open-source tool from Meta, automates the same logic as the three-snapshot technique. It launches headless Chrome through Puppeteer and, driven by your scenario file, takes three heap snapshots: a baseline after loading the url, a target after running your action (open a modal, navigate to a route), and a final after running back (close the modal, navigate back). It then looks for objects that were allocated between baseline and target and are still alive in the final snapshot — objects that the action created and the back step should have released.
Not every such object is a leak; the action might populate a cache on purpose. Memlab’s default heuristics focus on objects that are clearly suspicious — most notably detached DOM elements and unmounted framework fibres — and it clusters similar leaks together so that one bug produces one report rather than a thousand. For each cluster it prints a retainer trace: the shortest path from a GC root to a representative leaked object, the same information you would read from the Retainers panel in DevTools.
The quality of the result depends almost entirely on the scenario. back must return the app to the same logical state as the baseline — if it leaves a toast visible or keeps a route in history, Memlab correctly reports those objects. Actions must wait for the UI to settle rather than sleeping arbitrary times, and anything non-deterministic (timers, live data) must be controlled, or results will vary between runs. A custom leakFilter function lets you define exactly which surviving objects count as leaks for your app, which is how teams turn Memlab into a reliable CI gate.
Step-by-Step Fix
- Install Memlab. Add it as a dev dependency with
npm i -D memlab. Verification:npx memlab --helpprints the command list. - Write a scenario for one flow. Create
leaks/settings-dialog.jsexportingurl,actionandbackfunctions (see the code below). Makebackfully undoaction. Verification: running the steps manually in a browser returns the UI to its starting state. - Run it locally. Start your app on a local port and run
npx memlab run --scenario leaks/settings-dialog.js. Verification: Memlab prints progress for the three snapshots and then either “No leaks found” or a list of leak clusters with retainer traces. - Read the trace from the top. For each cluster, read the trace from the GC root downwards and stop at the first frame from your code — a component property, a module variable, a listener registration. Verification: you can name the reference that keeps the leaked objects alive.
- Fix and re-run until clean. Apply the fix (unsubscribe, remove listener, clear cache entry) and re-run the same scenario. Verification: the cluster disappears; if others remain, repeat.
- Add a filter for expected survivors, then gate CI. If the flow legitimately creates persistent objects, add a
leakFilterthat ignores them, then run Memlab in CI and fail the job on any reported leak. Verification: the CI job fails when you reintroduce the original bug on a branch.
Command and Code Reference
Use case: a scenario for a dialog that should leave nothing behind. Wait for real UI states instead of fixed delays so runs are stable.
// leaks/settings-dialog.js
module.exports = {
// Baseline: the page where the flow starts
url: () => 'http://localhost:5173/account',
// Target: perform the action suspected of leaking
action: async (page) => {
await page.click('[data-test=open-settings]');
await page.waitForSelector('[role=dialog][data-ready=true]');
},
// Final: undo the action completely
back: async (page) => {
await page.click('[role=dialog] [data-test=close]');
await page.waitForSelector('[role=dialog]', { hidden: true });
},
// Optional: decide what counts as a leak for this app
leakFilter(node, _snapshot, _leakedNodeIds) {
// Report detached DOM and any SettingsDialog-owned objects; ignore the
// i18n cache, which is intentionally populated on first open
if (node.name === 'I18nCacheEntry') return false;
return node.name.startsWith('Detached ') || node.name.includes('SettingsDialog');
},
};
Use case: run Memlab in CI and fail on leaks. Keep scenarios in the repository and run them against a production build.
#!/usr/bin/env bash
# ci-memlab.sh — build, serve, run every scenario, fail on any leak
set -euo pipefail
npm run build
npx vite preview --port 5173 & # or your static server
SERVER_PID=$!
trap 'kill $SERVER_PID' EXIT
npx wait-on http://localhost:5173
for scenario in leaks/*.js; do
echo "== $scenario"
# memlab exits non-zero if it cannot run; grep decides pass/fail on leaks
npx memlab run --scenario "$scenario" --work-dir "/tmp/memlab/$(basename "$scenario" .js)" | tee memlab.log
if grep -q "leak trace" memlab.log; then
echo "Leak detected in $scenario"; exit 1
fi
done
Verification and Regression Prevention
Memlab is doing its job when three conditions hold: every scenario passes on the main branch, reintroducing a known bug on a test branch makes the relevant scenario fail with a readable trace, and repeated runs of an unchanged build give the same result. If results flicker, stabilise the scenario — wait for explicit UI states, disable animations, mock slow or live APIs — before trusting it as a gate.
Grow the scenario suite from real incidents: every leak found in production or during development gets a scenario that reproduces it, so the fix is protected permanently. Keep scenarios small and focused on one flow each; a single mega-scenario that clicks through the whole app produces traces that are hard to attribute. Pair Memlab’s detached-DOM focus with a heap size budget in your CI pipeline to also catch growth in plain JavaScript objects that the heuristics may not classify as leaks.
Edge Cases and Gotchas
Repeating the action
A leak that happens once per open is easier to see if the action runs several times. Memlab supports repeating the action through its options; alternatively loop inside action/back so the leaked count is a clear multiple.
Authentication and setup
Scenarios that need a logged-in user can use a setup step or set cookies before navigation. Keep credentials for a dedicated test account in CI secrets, never in the scenario file.
Framework development builds
Development builds retain extra debugging structures and can report different objects than production. Run CI scenarios against production builds, and use development builds only when you need more readable names while investigating.
Snapshot size and CI time
Each run takes three full heap snapshots. On large apps that is slow and memory-hungry; give the CI runner enough memory, keep scenarios focused, and run the full suite nightly if running every scenario on every pull request is too slow.
Frequently Asked Questions
What does Memlab consider a leak by default?
Objects allocated during the action that survive the back step and match its heuristics — primarily detached DOM elements and unmounted framework component structures. You can replace the default decision with a leakFilter function that implements your own rule.
Do I need Puppeteer knowledge to write scenarios?
Only the basics: page.click, page.type, page.waitForSelector and page.goto cover most flows. The page object passed to action and back is a standard Puppeteer page, so any Puppeteer API works there.
Can Memlab analyse snapshots I captured myself?
Yes. Memlab’s analysis commands can work on existing snapshot files, and its heap analysis API lets you write custom analyses in JavaScript, such as finding the largest objects or objects of a given class across snapshots captured by other tools.
Related
- Automated Memory Leak Detection in CI — the parent topic
- Detecting Memory Leaks with Puppeteer Heap Snapshots — the lower-level approach Memlab builds on
- Reducing Noise in Automated Memory Tests — making leak tests stable enough to gate merges
- Browser DevTools & Performance Profiling Workflows — the section overview