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.
Step-by-Step Fix
- Enable the profiler before anything else. Create the CDP session and send
HeapProfiler.enable. Verification: subsequenttakeHeapSnapshotcalls emitaddHeapSnapshotChunkevents; if they do not, the session was created on the wrong target. - 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 thousandObject, some framework class names. - 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.
- Group both snapshots by constructor. Stride the
nodesarray bynode_fields.length, resolvenamethroughstrings, and sumself_size. Verification: total summed size is within a few per cent of the reported heap size; a large discrepancy means the stride is wrong. - 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
}
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.
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.
Related
- Automated Memory Leak Detection in CI — the surrounding gate this script plugs into
- Playwright Memory Testing for Single-Page Apps — the same capture through Playwright’s CDP session
- How to Take and Compare Heap Snapshots in Chrome — reading the file the job attached