Detecting Memory Leaks with Puppeteer Heap Snapshots

A heap-size gate tells you that something leaked; a snapshot diff tells you what, which is the difference between a red build someone investigates and a red build someone disables. This page belongs to automated memory leak detection in CI within Browser DevTools & Performance Profiling Workflows, and covers capturing real .heapsnapshot files from Puppeteer and reducing them to a constructor-level growth report.

Symptom Root Cause Immediate Action
Gate fails with only “heap grew 3.1 MB” No snapshot captured, so no attribution Capture two snapshots and diff by constructor
Renderer crashes during capture Container /dev/shm too small for the snapshot buffer Add --disable-dev-shm-usage to the launch args
Diff dominated by (system) and (compiled code) Engine-internal node types included in the report Filter to object and closure node types
Snapshot file is empty Chunk listener attached after the capture started Register the addHeapSnapshotChunk handler first
Growth reported on a known-clean build Snapshots taken without forcing collection Send HeapProfiler.collectGarbage twice before each capture

Root Cause: A Snapshot Is a Graph, Not a Number

A .heapsnapshot file is JSON describing a graph. The nodes array is a flat sequence of integers, where every node occupies a fixed number of fields described by snapshot.meta.node_fields — typically type, name, id, self_size, edge_count, trace_node_id and detachedness. Names are indices into a separate strings array, and types are indices into snapshot.meta.node_types[0]. Nothing in the file is keyed or labelled; you reconstruct meaning by striding through the array in blocks of node_fields.length.

That structure is why a diff is cheap to compute and a retainer path is not. Grouping nodes by constructor name and summing self_size is a single linear pass over the array — a few hundred milliseconds on a 70 MB snapshot. Reconstructing which object holds which requires walking the edges array and computing dominators, which is what DevTools does when you open the file and is not something worth reimplementing in a CI script.

So the useful division of labour is this: the script does the linear pass and reports which constructor grew and by how much, then attaches the raw file. A human opens that file in DevTools → Memory → Load and follows the retainer path in seconds, with all the context the script cannot provide. The script is not trying to find the bug; it is trying to make finding the bug a five-minute task instead of a reproduction exercise.

How the nodes array is laid out The file contains a meta descriptor listing seven node fields, a flat nodes array where every seven consecutive integers form one node record, and a strings table. The name field of each record is an index into that strings table, which is how a constructor name is recovered. One node = seven consecutive integers; the second one is a string index snapshot.meta.node_fields ["type", "name", "id", "self_size", "edge_count", "trace_node_id", "detachedness"] 3, 412, 1907, 96, 4, 0, 0 node 0 3, 412, 1908, 96, 4, 0, 0 node 1 3, 861, 1909, 48, 2, 0, 1 node 2 · detached strings[412] = "CacheEntry" strings[861] = "HTMLDivElement" the name field is an index, never the text itself

Step-by-Step Fix

  1. Enable the profiler before anything else. Create the CDP session and send HeapProfiler.enable. Verification: subsequent takeHeapSnapshot calls emit addHeapSnapshotChunk events; if they do not, the session was created on the wrong target.
  2. Warm the flow, then capture the baseline. Run the interaction once, send HeapProfiler.collectGarbage, then capture. Verification: the file parses and reports a plausible constructor list — several thousand Object, some framework class names.
  3. Repeat the interaction N times and capture again. Verification: the second file is larger than the first on a leaking build and within a per cent or two on a clean one.
  4. Group both snapshots by constructor. Stride the nodes array by node_fields.length, resolve name through strings, and sum self_size. Verification: total summed size is within a few per cent of the reported heap size; a large discrepancy means the stride is wrong.
  5. Report growth and attach the artefact. Sort by after − before, print the top five, and on failure keep both files. Verification: the top entry names something recognisable from your codebase, not (system).

Command & Code Reference

Capturing a snapshot to disk over CDP. Note that the chunk listener is attached before the capture begins.

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

async function captureSnapshot(client, path) {
  const chunks = [];
  const onChunk = ({ chunk }) => chunks.push(chunk);
  client.on('HeapProfiler.addHeapSnapshotChunk', onChunk);   // BEFORE the call
  // Two collections: the first can leave objects awaiting a second pass.
  await client.send('HeapProfiler.collectGarbage');
  await client.send('HeapProfiler.collectGarbage');
  await client.send('HeapProfiler.takeHeapSnapshot', { reportProgress: false });
  client.off('HeapProfiler.addHeapSnapshotChunk', onChunk);
  fs.writeFileSync(path, chunks.join(''));
  return path;
}

Reducing a snapshot to bytes-per-constructor in one linear pass over the flat node array.

function constructorTotals(file) {
  const snap = JSON.parse(require('node:fs').readFileSync(file, 'utf8'));
  const { node_fields: fields, node_types: types } = snap.snapshot.meta;
  const stride = fields.length;
  const typeIdx = fields.indexOf('type');
  const nameIdx = fields.indexOf('name');
  const sizeIdx = fields.indexOf('self_size');
  // Only real objects and closures — the rest is engine bookkeeping.
  const interesting = new Set(['object', 'closure']);
  const totals = new Map();

  for (let i = 0; i < snap.nodes.length; i += stride) {
    const kind = types[0][snap.nodes[i + typeIdx]];
    if (!interesting.has(kind)) continue;
    const name = snap.strings[snap.nodes[i + nameIdx]];
    totals.set(name, (totals.get(name) ?? 0) + snap.nodes[i + sizeIdx]);
  }
  return totals;
}

function reportGrowth(beforeFile, afterFile, topN = 5) {
  const before = constructorTotals(beforeFile);
  const after = constructorTotals(afterFile);
  const rows = [...after].map(([name, bytes]) =>
    ({ name, growth: bytes - (before.get(name) ?? 0) }));
  return rows.sort((a, b) => b.growth - a.growth).slice(0, topN);
}

Wiring it into a job that fails loudly and leaves evidence behind.

const BUDGET_BYTES = 512 * 1024;   // per-run growth allowed for any one constructor

const top = reportGrowth('base.heapsnapshot', 'after.heapsnapshot');
for (const { name, growth } of top) {
  console.log(`${name.padEnd(32)} ${(growth / 1024).toFixed(1)} KB`);
}
if (top[0] && top[0].growth > BUDGET_BYTES) {
  console.error(`FAIL ${top[0].name} grew ${(top[0].growth / 1024).toFixed(1)} KB`);
  process.exit(1);          // artefacts are kept; snapshots stay on disk
}
What the job prints when it fails Five constructors listed by growth between the two snapshots. Detached HTMLDivElement grew by two point one megabytes, well over the budget line at five hundred and twelve kilobytes. The remaining four rows grew by under sixty kilobytes each and sit far below the line. Constructor growth between the two snapshots, sorted budget 512 KB Detached HTMLDivElement 2 148 KB — fails the build CacheEntry 52 KB Object 31 KB Array 18 KB The first row names the bug; the attached snapshot shows who is holding it.

Verification & Regression Prevention

Confirm the harness before trusting it: introduce a deliberate leak — push a DOM node into a module-scope array on every interaction — and check that the report names it. A gate that has never been shown to catch a known leak is a gate nobody should rely on. Then confirm the opposite direction by running the same commit twice; the top constructor’s growth should differ by only a few per cent between runs, and if it does not, the interaction is not deterministic yet.

For the ongoing job, keep three artefacts on failure: both snapshots and the printed report. Delete them on success, since two files at roughly 1.2× heap size each will fill a runner’s disk within days otherwise. Record the top constructor’s growth as a time series alongside the pass or fail result, because a value that climbs from 30 KB to 400 KB over ten commits is a leak forming under a budget that has not yet been crossed.

Validating the harness with a known leak Three stages: a deliberate leak is added to the build, the harness is run and must name the leaking constructor, and only then is the gate enabled for the whole team. A fourth box notes that a harness that has never caught a known leak provides no evidence of anything. Prove the gate works before asking anyone to trust it 1 · inject a known leak push a DOM node into a module-scope array per click 2 · run the harness it must name the constructor, not merely report growth 3 · enable for the team now a red build carries real evidence with it Repeat step 2 on the same commit twice: the top constructor's growth should match to within a few per cent. If it does not, the interaction is still non-deterministic and every future failure will be argued about.

FAQ

How large are the snapshot files in CI?

Roughly 1.2 times the size of the JavaScript heap, so a 60 MB heap produces about a 72 MB file. Two snapshots per run is therefore around 150 MB of temporary disk; keep them only for failing runs and delete them on success, or CI storage becomes the bottleneck long before the gate does.

Can this run in a container without a display?

Yes. Puppeteer’s bundled Chromium runs headless with --no-sandbox in most container images, and the heap profiler does not need a display. What it does need is enough shared memory: pass --disable-dev-shm-usage or mount a larger /dev/shm, or snapshot capture on a large heap will crash the renderer.

Why does the diff show growth in constructors I never wrote?

Snapshots include engine-internal node types such as (system), (compiled code) and hidden classes. Filter to node type object and closure before reporting, or the top of your growth list will be dominated by internals that move for reasons unrelated to your code.