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.

How a Memlab run works Three scenario steps each followed by a heap snapshot: url then baseline, action then target, back then final. An analysis box compares them to find objects allocated between baseline and target that survive in final, applies leakFilter, clusters similar objects, and outputs retainer traces for each leak cluster. url() → baseline snapshot action(page) → target snapshot back(page) → final snapshot Analysis allocated between baseline and target, alive in final → leakFilter (yours) or built-in heuristics → cluster similar objects → one retainer trace each

Step-by-Step Fix

  1. Install Memlab. Add it as a dev dependency with npm i -D memlab. Verification: npx memlab --help prints the command list.
  2. Write a scenario for one flow. Create leaks/settings-dialog.js exporting url, action and back functions (see the code below). Make back fully undo action. Verification: running the steps manually in a browser returns the UI to its starting state.
  3. 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.
  4. 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.
  5. 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.
  6. Add a filter for expected survivors, then gate CI. If the flow legitimately creates persistent objects, add a leakFilter that 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.
Reading a Memlab retainer trace A trace starts at the Window root, goes through the store object, its listeners array, a closure at index 3 created by SettingsDialog, the closure's context, and ends at a detached HTMLDivElement representing the dialog. The listeners array entry is highlighted as the first frame the application owns. [Window] (native) @1 --store (property)---> [Store] (object) @8123 --listeners (property)---> [Array] (object) @8131 ← first frame you own --3 (element)---> [onStoreChange] (closure) @9910 --context (internal)---> [SettingsDialog context] @9911 --dialogEl (variable)---> [Detached HTMLDivElement] @10433 × 1 per open

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.

Three signs a Memlab gate is trustworthy A Memlab scenario earns its place in CI when it passes on the main branch, fails with a readable trace when a known leak is reintroduced on a test branch, and gives the same result across repeated runs of an unchanged build. Before a scenario becomes a gate Green on main Every scenario passes on the main branch with no leak clusters reported. Red on a known bug Reintroducing a past leak on a test branch fails the scenario with a readable retainer trace. Stable on repeat Repeated runs of an unchanged build agree; flicker means wait for explicit UI states first.

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.