Automated Memory Leak Detection in CI

Every leak this section teaches you to find by hand — a detached DOM node surviving a route change, a closure leak holding a response body — can be caught by a machine before it reaches a branch, and this guide within Browser DevTools & Performance Profiling Workflows covers how to build that machine. The technique is the same three-snapshot discipline you would run in the Chrome DevTools Memory Tab, executed headlessly and reduced to a single number a pipeline can compare against a budget.

Conceptual Grounding: What a Machine Can Measure

A human reading a heap snapshot answers “what is holding this object”. A CI job cannot answer that, and should not try. What it can answer reliably is narrower and still valuable: did repeating an identical interaction leave measurably more memory behind than it did on the last commit?

That question reduces to one number — bytes retained per iteration — and getting it stable depends on three things. First, the interaction has to be genuinely identical between runs: same route, same fixture data, same number of repetitions, no randomised identifiers that change how much string data the heap holds. Second, both readings have to be taken after a forced collection, because an unforced reading is dominated by whatever garbage happened to be in the nursery at that instant; the DevTools Protocol exposes HeapProfiler.collectGarbage for exactly this. Third, the comparison has to be a per-iteration slope rather than an absolute size, because absolute heap size drifts with browser version, font caching, and the size of the bundle itself.

What the CI job actually measures Five stages in sequence: launch the production build headlessly, warm the flow once and force a collection to take a baseline, repeat the interaction N times, force a collection and take a second reading, then divide the difference by N and compare the result against a committed budget. One number out: bytes retained per iteration 1 · launch production bundle, headless, clean profile 2 · baseline warm once, collect, read JSHeapUsedSize 3 · repeat ×N identical interaction, fixed fixture data 4 · re-measure collect again, read the second value 5 · judge Δ ÷ N against the budget Why steps 2 and 4 must both force a collection Without it, the reading includes whatever short-lived garbage happened to be in the nursery, which varies by tens of megabytes between runs and drowns the signal you are looking for.

The output of that pipeline is deliberately dull: a single float, compared against a number committed to the repository. Everything interesting — which object, which retainer — happens afterwards, in DevTools, using the snapshot the failing job attached.

Diagnostic Workflow

  1. Pick one flow and freeze it. Choose the interaction that historically leaks: a route transition, a modal open/close, a list re-render. DevTools path for the manual equivalent: DevTools → Memory → Heap snapshot, run it by hand first and confirm you can see the growth. Expected: a manual three-snapshot comparison shows a non-zero Size Delta before you automate anything.
  2. Warm the flow once before the baseline. Run the interaction a single time, then force collection. Command: await client.send('HeapProfiler.collectGarbage'). Expected: the first run allocates module singletons and lazily compiled code — roughly 2–6 MB on a typical bundle — which must land before the baseline, not inside the measured window.
  3. Take the baseline reading. Command: Performance.getMetrics and read the JSHeapUsedSize entry. Expected: a value stable to within about 1% across three consecutive readings; if it is not, the app still has timers or animations running and needs to be quiesced first.
  4. Run the interaction N times. Ten is the practical floor for a per-pull-request gate; use 50–100 for a nightly job. Expected: wall-clock time under 60 seconds for the short version, or engineers will disable it.
  5. Force collection and re-read. Expected: a healthy flow returns to within a few hundred kilobytes of baseline; a leaking one shows a delta that scales linearly with N — which is itself the confirmation that it is a leak and not warm-up.
  6. Divide, compare, publish. Expected: the job prints retained per iteration: 41.2 KB (budget 120 KB) — PASS, and on failure writes a .heapsnapshot artefact plus the top constructors by growth.

Code Patterns & Signatures

The core measurement loop, written against the Chrome DevTools Protocol through Puppeteer. This is the whole gate in thirty lines.

const puppeteer = require('puppeteer');

// N is the iteration count; raise it for nightly runs.
async function measureRetention({ url, interaction, iterations = 10 }) {
  const browser = await puppeteer.launch({ args: ['--no-sandbox'] });
  const page = await browser.newPage();
  const client = await page.createCDPSession();
  await client.send('Performance.enable');
  await page.goto(url, { waitUntil: 'networkidle0' });

  // Warm-up: the first pass allocates singletons and JIT code that must
  // NOT be counted as growth. Run it, then settle the heap.
  await interaction(page);
  await client.send('HeapProfiler.collectGarbage');

  const readHeap = async () => {
    const { metrics } = await client.send('Performance.getMetrics');
    return metrics.find((m) => m.name === 'JSHeapUsedSize').value;
  };

  const baseline = await readHeap();
  for (let i = 0; i < iterations; i++) await interaction(page);

  // Two collections: the first can leave objects awaiting a second pass.
  await client.send('HeapProfiler.collectGarbage');
  await client.send('HeapProfiler.collectGarbage');
  const after = await readHeap();

  await browser.close();
  return { perIteration: (after - baseline) / iterations, baseline, after };
}

The interaction itself must be deterministic. Note the fixed fixture id and the explicit wait for a settled state rather than a fixed timeout — an arbitrary sleep is the most common source of a flaky memory gate.

// Open a detail panel and close it again — the classic leak-prone flow.
async function openAndCloseDetail(page) {
  await page.click('[data-testid="row-42"]');          // fixed row, never random
  await page.waitForSelector('[data-testid="detail-panel"]');
  await page.click('[data-testid="detail-close"]');
  // Wait for the panel to actually leave the DOM, not for a guessed delay.
  await page.waitForFunction(
    () => !document.querySelector('[data-testid="detail-panel"]')
  );
}

When the gate fails, this dumps the artefact that makes the failure actionable: a real snapshot plus the constructor-level growth summary.

const fs = require('node:fs');

// Stream a .heapsnapshot to disk through the CDP chunk events.
async function writeSnapshot(client, path) {
  const chunks = [];
  const onChunk = ({ chunk }) => chunks.push(chunk);
  client.on('HeapProfiler.addHeapSnapshotChunk', onChunk);
  await client.send('HeapProfiler.takeHeapSnapshot', {
    reportProgress: false,
    captureNumericValue: true,
  });
  client.off('HeapProfiler.addHeapSnapshotChunk', onChunk);
  fs.writeFileSync(path, chunks.join(''));   // open this in DevTools → Memory → Load
}
Why the budget is a slope, not a size Four builds plotted twice. Absolute heap size drifts upward as the bundle grows, crossing a fixed absolute threshold on build three even though nothing leaked. Per-iteration retention stays flat across builds one to three and jumps only on build four, which is the build that actually introduced a leak. A fixed heap-size threshold fails the wrong build Absolute heap after the run fixed threshold build 1 build 2 build 3 — false alarm build 4 Retained per iteration budget 120 KB 41 KB 44 KB 43 KB 310 KB — real leak Build 3 added a legitimate feature; only build 4 fails to release what it allocated.

Choosing the Iteration Count and the Budget

The two numbers that decide whether the gate is useful are the iteration count and the budget, and both are empirical rather than principled. Start by running the loop against a build you believe is clean, at ten, twenty and fifty iterations. A clean build produces a per-iteration figure that is roughly constant across all three: if ten iterations report 40 KB each and fifty report 41 KB each, the measurement is linear and the gate will behave. If the figure falls as iterations rise — 120 KB at ten, 45 KB at fifty — you are still measuring warm-up, and the fix is another warm-up pass before the baseline rather than a larger budget.

With a linear figure in hand, run the same build five times and take the spread. A well-behaved flow on a pinned browser version typically varies by two to four per cent. Set the budget at the observed mean plus three times that spread, rounded to something memorable, and commit it next to the test with a comment recording the date and the build it was calibrated on. That comment matters more than it looks: six months later, the only way to know whether 120 KB was a considered number or a placeholder is to have written it down.

Iteration count is a trade against wall-clock time in the pull-request path. Ten iterations of a route transition is typically 30 to 45 seconds including browser start-up, which engineers tolerate. Fifty iterations is three minutes, which they do not, and a gate that gets disabled protects nothing. The nightly job is where the large counts belong, because that is where slow accumulations — a few kilobytes per iteration that only become visible over hundreds of repetitions — actually show up.

One refinement is worth the effort once the basic gate is stable: record the per-iteration figure as a time series rather than only comparing it against the budget. A build that moves from 41 KB to 78 KB has doubled its retention and is still comfortably inside a 120 KB budget, which means the gate stays green while the application slowly gets worse. Charting the series makes that visible, and it converts the budget from a cliff into a backstop.

Symptom-to-Fix Reference Table

Symptom Root Cause Immediate Action Measurable Impact
Gate result swings ±40% between runs on the same commit Reading taken without a forced collection, or after a fixed sleep Force collection twice and wait for a settled heap before each reading Run-to-run variance drops from ~40% to under 5%
Gate reports growth on every build including known-clean ones Warm-up allocations counted inside the measured window Run the interaction once before the baseline reading Removes the constant 2–6 MB one-off offset
Gate passes locally, fails in CI Different browser build or a device-scale-factor difference changing layout allocations Pin the browser version and viewport in both places Eliminates the class of failure entirely
Failure gives no clue what leaked No artefact published Write the .heapsnapshot and top-growth constructors on failure Mean time to fix drops from hours to minutes
Delta grows even with iterations set to 1 Not a leak — the flow allocates a large cache on first use Move the measurement window after the cache is warm Prevents permanently disabling a useful gate
Job takes 6 minutes and gets disabled Iteration count tuned for a nightly run applied per pull request Split into a 10-iteration PR gate and a 100-iteration nightly one PR feedback under 60 s, coverage unchanged
Two tiers: the pull-request gate and the nightly run The pull-request gate runs one flow for ten iterations in about forty seconds and catches obvious regressions. The nightly job runs every major flow for a hundred iterations over twenty minutes and catches slow accumulations that only appear after many repetitions. Two jobs, two purposes — one job cannot do both Per pull request Scope · one representative flow Iterations · 10 Wall clock · ~40 s including start-up Catches: a forgotten teardown, a new subscription, a cache with no bound — while it is still one commit. Nightly Scope · every major flow Iterations · 100 Wall clock · ~20 min Catches: a few kilobytes per iteration that no ten-iteration run could distinguish from noise. Running the nightly configuration on every pull request is how a memory gate gets disabled in its first month; running only the short one is how a slow leak reaches production unnoticed.

Edge Cases & Gotchas

Animations and polling never stop, so the heap never settles. A dashboard with a one-second poll allocates continuously, and the “settled” reading you take is really a random sample of the sawtooth. Pause the polling for the measurement — a query parameter the test harness sets is enough — or accept a budget wide enough to swallow one poll cycle, which usually means the gate can no longer see a small leak.

Headless and headed Chrome allocate differently. Headless mode skips some compositing structures, so a budget calibrated headed will read low headless and vice versa. Pick one mode and use it everywhere, including when an engineer reproduces the failure locally.

Two collections are not always enough. Objects whose finalisation is deferred, and anything reachable only through a WeakRef, can survive a single collection cycle and disappear on the next. Calling HeapProfiler.collectGarbage twice, as the sample above does, removes almost all of this noise; a third call buys nothing measurable.

A budget that is never tightened stops working. Commit the budget next to the test and lower it whenever a fix lands, otherwise you preserve today’s leak as tomorrow’s baseline. Treat the budget file the way you would treat a bundle-size budget: it ratchets down, never up, without an explicit review.

The gate cannot see leaks outside the JavaScript heap. Detached-but-referenced canvases, ArrayBuffers and decoded images live in external memory outside the JS heap, which JSHeapUsedSize does not count. If your product is image- or canvas-heavy, add a second assertion on performance.measureUserAgentSpecificMemory(), which does include those bytes.

FAQ

Are heap measurements stable enough to fail a build on?

Absolute heap size is not stable, but the per-iteration delta after a forced collection is. Run the interaction at least ten times, divide the delta by the iteration count, and set the budget at roughly three times the observed variance of a known-good build. Gates written that way typically vary by under 5% between runs on the same commit.

Should the gate run on every pull request?

Run a short version — one flow, ten iterations, about 40 seconds — on every pull request, and a long version covering every major flow nightly. The short run catches the obvious regressions while they are still cheap to fix; the long run catches slow accumulations that only appear after hundreds of iterations.

Can this run against a production build or does it need a debug build?

Run it against the production bundle. Development builds carry extra instrumentation, unminified names and framework warnings that change both the absolute heap size and which objects survive, so a budget calibrated on a development build will not transfer.

What should the job publish when it fails?

A .heapsnapshot file from the final iteration, the per-iteration delta, and the top five constructors by retained-size growth. The snapshot is what turns a red build into a five-minute fix instead of a reproduction exercise.