
Heap Snapshot Analysis
- 107 installs
- 188k repo stars
- Updated July 28, 2026
- microsoft/vscode
Analyze V8 heap snapshots to investigate memory leaks and retention issues.
About
Analyze V8 heap snapshots to investigate memory leaks and retention issues. Use when given .heapsnapshot files, asked to compare before/after snapshots, asked to find what retains objects, or investigating why objects survive GC. Provides snapshot parsing, comparison, retainer-path helpers, and scratchpad scripts. Investigate memory leaks from V8 heap snapshots (`.heapsnapshot` files). This skill starts when snapshots already exist: either the user provided them, DevTools exported them, or another workflow produced them. Use the helpers here to compare snapshots, group object deltas, and trace retainer paths.
- ## IGNORE Prior Investigations
- **Start every investigation fresh.** Do NOT read, consult, or be influenced by prior investigations found in:
- `/memories/` (user, session, or repo memory)
- `.github/skills/heap-snapshot-analysis/scratchpad/` (previous dated subfolders and their `findings.md` files)
- Any other notes from earlier sessions
Heap Snapshot Analysis by the numbers
- 107 all-time installs (skills.sh)
- Ranked #4,054 of 16,659 AI & Agent Building skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
heap-snapshot-analysis capabilities & compatibility
- Capabilities
- ## ignore prior investigations · **start every investigation fresh.** do not read · `/memories/` (user, session, or repo memory) · `.github/skills/heap snapshot analysis/scratchpa
- Use cases
- documentation
What heap-snapshot-analysis says it does
Analyze V8 heap snapshots to investigate memory leaks and retention issues. Use when given .heapsnapshot files, asked to compare before/after snapshots, asked to find what retains objects, or investig
npx skills add https://github.com/microsoft/vscode --skill heap-snapshot-analysisAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 107 |
|---|---|
| repo stars | ★ 188k |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 28, 2026 |
| Repository | microsoft/vscode ↗ |
How do I apply heap-snapshot-analysis using the workflow in its SKILL.md?
Analyze V8 heap snapshots to investigate memory leaks and retention issues. Use when given .heapsnapshot files, asked to compare before/after snapshots, asked to find what retains objects...
Who is it for?
Developers following the heap-snapshot-analysis skill for the tasks it documents.
Skip if: Tasks outside the heap-snapshot-analysis scope described in SKILL.md.
When should I use this skill?
User mentions heap-snapshot-analysis or related triggers from the skill description.
What you get
Working heap-snapshot-analysis setup aligned with the documented patterns and constraints.
Files
Heap Snapshot Analysis
Investigate memory leaks from V8 heap snapshots (.heapsnapshot files). This skill starts when snapshots already exist: either the user provided them, DevTools exported them, or another workflow produced them. Use the helpers here to compare snapshots, group object deltas, and trace retainer paths.
IGNORE Prior Investigations
Start every investigation fresh. Do NOT read, consult, or be influenced by prior investigations found in:
/memories/(user, session, or repo memory).github/skills/heap-snapshot-analysis/scratchpad/(previous dated subfolders and theirfindings.mdfiles)- Any other notes from earlier sessions
Previous findings can bias the analysis toward suspects that are no longer relevant, or cause the agent to skip steps and jump to conclusions. Let the current snapshots speak for themselves. Only reference prior work if the user explicitly asks you to.
When to Use
- User provides
.heapsnapshotfiles (before/after a workflow) - User has heap snapshots captured by another skill or script
- Need to find what retains disposed objects (retainer path analysis)
- Comparing object counts/sizes between two snapshots
- Investigating why particular objects survive GC
Workflow
If the user needs the agent to launch VS Code, drive a scenario, and capture snapshots first, use the VS Code performance workflow skill before returning here for low-level snapshot analysis.
1. Parse Snapshots
Use the helpers in parseSnapshot.ts to load snapshots. The files are often >500MB and too large for JSON.parse as a string — the helpers use Buffer-based extraction. In scratchpad scripts, import helpers from ../helpers/*.ts.
For very large snapshots, the helper may still be too eager. Node cannot create a Buffer larger than roughly 2 GiB, so snapshots above that size can fail with ERR_FS_FILE_TOO_LARGE even before parsing. In that case, do not try to raise --max-old-space-size and retry the same full-file read. Switch to a streaming script.
import { parseSnapshot, buildGraph } from '../helpers/parseSnapshot.ts';
const data = parseSnapshot('/path/to/snapshot.heapsnapshot');
const graph = buildGraph(data);Snapshots Larger Than 2 GiB
When a snapshot is too large to load into a single Buffer, write scratchpad scripts that scan and parse only the sections needed for the question. Use streamSnapshot.mjs for the common streaming primitives instead of copying them between scratch scripts.
Useful tricks:
- Find top-level section offsets first. Scan the file as bytes for markers like
"nodes":,"edges":,"strings":, and"trace_function_infos":. This lets follow-up scripts jump directly to the large arrays instead of searching the whole file repeatedly. - Parse
snapshot.metaseparately from the small header at the start of the file. Usemeta.node_fields,meta.node_types,meta.edge_fields, andmeta.edge_typesto avoid hard-coding tuple widths. - Stream numeric arrays in chunks. For
nodesandedges, keep a small carryover string between chunks, split on commas, and process complete numeric tokens as they arrive. - Avoid materializing the full
stringstable unless the investigation truly needs it. If you only need suspicious names, collect string indexes from matching nodes/edges first, then resolve only those indexes in a second streaming pass. - If you do need many strings, store only short previews and category counters. Full source strings, ref-listing strings, and prompt payloads can dominate memory and make the analyzer become the leak.
- Write intermediate outputs to files in the scratchpad. Large heap analysis is iterative and slow; cached node ids, offsets, and retainer traces save repeated multi-minute passes.
- Prefer self-size attribution and field-level ownership for huge graphs. Full retained-size walks can wildly overcount shared services, roots, maps, and singleton caches.
- When quantifying a suspected owner, count obvious owned fields separately: wrapper object, key arrays, array elements, direct strings, and parent strings of sliced/concatenated strings. This often gives a better lower-bound than a single direct string bucket.
- Be explicit about approximation boundaries. A field-level subtotal usually undercounts listeners/watchers/back-references but avoids the much worse problem of attributing the whole runtime to one object.
Example large-snapshot workflow:
import { findArrayStart, findTokenOffsets, parseMeta, streamNumberTuples } from '../../helpers/streamSnapshot.mjs';
const { size, offsets } = findTokenOffsets(snapshotPath);
const meta = parseMeta(snapshotPath);
const nodeFieldCount = meta.node_fields.length;
const nodesStart = findArrayStart(snapshotPath, offsets.get('"nodes"'));
streamNumberTuples(snapshotPath, nodesStart, offsets.get('"edges"'), nodeFieldCount, (node, nodeIndex) => {
// node is reused for speed; copy it before storing.
});cd .github/skills/heap-snapshot-analysis
node --max-old-space-size=24576 scratchpad/YYYY-MM-DD-topic/findOffsets.mjs /path/to/Heap.heapsnapshot
node --max-old-space-size=24576 scratchpad/YYYY-MM-DD-topic/streamAnalyze.mjs /path/to/Heap.heapsnapshot > scratchpad/YYYY-MM-DD-topic/streamAnalyze.out
node --max-old-space-size=24576 scratchpad/YYYY-MM-DD-topic/traceNodes.mjs /path/to/Heap.heapsnapshot 12345 67890 > scratchpad/YYYY-MM-DD-topic/traceNodes.out2. Compare Before/After
Use compareSnapshots.ts to diff two snapshots:
import { compareSnapshots } from '../helpers/compareSnapshots.ts';
const result = compareSnapshots('/path/to/before.heapsnapshot', '/path/to/after.heapsnapshot');
// result.topBySize, result.topByCount, result.newObjectGroups, result.summary3. Find Retainer Paths
Use findRetainers.ts to trace why an object is alive:
import { findRetainerPaths } from '../helpers/findRetainers.ts';
// Find what keeps ChatModel instances alive (skipping weak edges)
findRetainerPaths(graph, 'ChatModel', { maxPaths: 5, maxDepth: 25, maxAttempts: 200 });4. Write Investigation Scripts
Write investigation-specific scripts in the scratchpad directory. This folder is gitignored — use it freely for one-off analysis.
Organize scratchpad work into dated subfolders named YYYY-MM-DD-short-description/ (e.g., 2026-04-09-chat-model-retainers/). Each subfolder should contain:
- The analysis scripts (
.mjs,.mts, etc.) - A `findings.md` file documenting the full investigation: all ideas considered, which ones led to changes and which were rejected (and why), before/after measurements, and a summary of the outcome. This lets the user review the agent's reasoning, decide which changes to keep, and follow up on deferred ideas.
Scripts can import the helpers:
cd .github/skills/heap-snapshot-analysis
node --max-old-space-size=16384 scratchpad/2026-04-09-chat-model-retainers/analyze.mjsKey Concepts
V8 Heap Snapshot Format
The .heapsnapshot file is JSON with these key sections:
- `snapshot.meta`: Field definitions for nodes and edges
- `nodes`: Flat array, every N values = one node (N =
meta.node_fields.length, typically 6:type, name, id, self_size, edge_count, detachedness) - `edges`: Flat array, every M values = one edge (M =
meta.edge_fields.length, typically 3:type, name_or_index, to_node) - `strings`: String table indexed by
namefields in nodes/edges
Edge Types That Matter
| Type | Meaning | Prevents GC? |
|---|---|---|
property | Named JS property | Yes |
element | Array index | Yes |
context | Closure variable | Yes |
internal | V8 internal reference | Yes |
hidden | V8 hidden reference | Yes |
| `weak` | WeakRef/WeakMap key | No |
shortcut | Convenience link | Depends |
Always skip `weak` edges when tracing retainer paths. WeakMap entries show up as edges from key → backing array, but they don't prevent collection — they're red herrings.
Common VS Code Retention Patterns
1. RowCache templates: ListView's RowCache stores template rows. Templates have currentElement pointing to old viewmodel items. If not cleared on session switch, retains entire model chains.
2. Resource pools: pool.clear() only disposes idle items. If _onDidUpdateViewModel.fire() runs AFTER pool.clear(), released items re-enter the empty pool and are never disposed. Fire event first, then clear.
3. `autorunIterableDelta` lastValues: The closure captures a Map of previous iteration values. Values stay until the autorun re-runs. Async disposal delays keep models in observable stores longer than expected.
4. `HoverService._delayedHovers`: Global singleton Map retaining disposed objects via show closure → resolveHoverOptions closure → this. If hover cleanup disposable doesn't fire, the entire object tree is retained.
5. `ObjectMutationLog._previous`: The incremental serializer keeps a full snapshot of the last-serialized state. Every loaded ChatModel holds 2x its data: live + _previous.
6. `_previousModelRef` pattern: MutableDisposable setter disposes the old value. Reading .value and storing it elsewhere, then setting .value = undefined, disposes the stored reference. Use clearAndLeak() to extract without disposing.
Defensive Nulling
Null heavy fields in dispose() to break retention chains even when something retains the disposed object:
override dispose() {
super.dispose();
this._requests.length = 0; // conversation data
this.dataSerializer = undefined; // serialization snapshot
this._editingSession = undefined; // editing session + TextModels
this._session = undefined!; // back-reference cycles
}Caveat: Don't null fields on viewmodel items (ChatResponseViewModel._model). The tree's diffIdentityProvider accesses them after the parent viewmodel is disposed but before setChildren replaces them.
False Retainers to Watch For
- DevTools debugger global handles: If the snapshot was captured after opening DevTools, large source strings, compiled scripts, preview data, inspected objects, or debugger bookkeeping can be retained by paths like
DevTools debugger(internal)→synthetic::(Global handles)→ GC roots. Treat these as debugger-induced until proven otherwise. They may not exist in the app before DevTools opens, and they should not be confused with application-owned leaks. - `DevToolsLogger._aliveInstances` (Map): Enabled by
VSCODE_DEV_DEBUG_OBSERVABLESenv var. Retains ALL observed observables. Check if this is active before investigating observable-rooted paths. - `GCBasedDisposableTracker` (FinalizationRegistry): If
register(target, held, target)is used (target === unregister token), creates a strong self-reference preventing GC. Currently commented out in production. - WeakMap backing arrays: Show up in retainer paths but don't prevent collection.
Running Analysis
All helper scripts use ESM and need Node with extra memory:
node --max-old-space-size=16384 scratchpad/analyze.mjsTypical analysis takes 30-120 seconds per snapshot depending on size.
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
/**
* Heap Snapshot Comparison
*
* Compares two V8 heap snapshots (before/after) and reports:
* - Top object groups by size increase
* - Top object groups by count increase
* - New object groups (only in "after")
* - VS Code-specific class changes
*/
import { parseSnapshot, collectNodeIds, type SnapshotData } from './parseSnapshot.ts';
export interface ComparisonGroup {
key: string;
type: string;
name: string;
beforeCount: number;
afterCount: number;
countDiff: number;
beforeSize: number;
afterSize: number;
sizeDiff: number;
}
export interface ComparisonResult {
summary: {
beforeNodes: number;
afterNodes: number;
beforeSize: number;
afterSize: number;
nodeDelta: number;
sizeDelta: number;
};
/** Top groups by size increase */
topBySize: ComparisonGroup[];
/** Top groups by count increase */
topByCount: ComparisonGroup[];
/** Groups that only exist in the "after" snapshot */
newObjectGroups: ComparisonGroup[];
}
function groupByConstructor(data: SnapshotData, filterIds?: Set<number>) {
const { meta, nodes, strings } = data;
const nfc = meta.node_fields.length;
const nodeTypes = meta.node_types[0];
const typeIdx = meta.node_fields.indexOf('type');
const nameIdx = meta.node_fields.indexOf('name');
const idIdx = meta.node_fields.indexOf('id');
const selfSizeIdx = meta.node_fields.indexOf('self_size');
const groups = new Map<string, { type: string; name: string; count: number; totalSize: number }>();
let totalSize = 0;
for (let i = 0; i < nodes.length; i += nfc) {
const id = nodes[i + idIdx];
if (filterIds && !filterIds.has(id)) { continue; }
const typeName = nodeTypes[nodes[i + typeIdx]];
const name = strings[nodes[i + nameIdx]];
const selfSize = nodes[i + selfSizeIdx];
totalSize += selfSize;
const key = `${typeName}::${name}`;
let g = groups.get(key);
if (!g) {
g = { type: typeName, name, count: 0, totalSize: 0 };
groups.set(key, g);
}
g.count++;
g.totalSize += selfSize;
}
return { groups, totalSize, nodeCount: nodes.length / nfc };
}
/**
* Compare two heap snapshots and return the differences.
*/
export function compareSnapshots(beforePath: string, afterPath: string, topN = 50): ComparisonResult {
const beforeData = parseSnapshot(beforePath);
const beforeResult = groupByConstructor(beforeData);
const beforeIds = collectNodeIds(beforeData);
// Free memory
beforeData.nodes = null!;
beforeData.edges = null!;
const afterData = parseSnapshot(afterPath);
const afterResult = groupByConstructor(afterData);
const newIds = new Set<number>();
{
const nfc = afterData.meta.node_fields.length;
const idIdx = afterData.meta.node_fields.indexOf('id');
for (let i = 0; i < afterData.nodes.length; i += nfc) {
const id = afterData.nodes[i + idIdx];
if (!beforeIds.has(id)) { newIds.add(id); }
}
}
const newObjectResult = groupByConstructor(afterData, newIds);
afterData.nodes = null!;
afterData.edges = null!;
// Compute diffs
const diffs: ComparisonGroup[] = [];
for (const [key, afterGroup] of afterResult.groups) {
const beforeGroup = beforeResult.groups.get(key);
const beforeCount = beforeGroup?.count ?? 0;
const beforeSize = beforeGroup?.totalSize ?? 0;
const countDiff = afterGroup.count - beforeCount;
const sizeDiff = afterGroup.totalSize - beforeSize;
if (countDiff > 0 || sizeDiff > 1024) {
diffs.push({
key,
type: afterGroup.type,
name: afterGroup.name,
beforeCount,
afterCount: afterGroup.count,
countDiff,
beforeSize,
afterSize: afterGroup.totalSize,
sizeDiff,
});
}
}
const topBySize = [...diffs].sort((a, b) => b.sizeDiff - a.sizeDiff).slice(0, topN);
const topByCount = [...diffs].sort((a, b) => b.countDiff - a.countDiff).slice(0, topN);
const newGroups = [...newObjectResult.groups.values()]
.map(g => ({
key: `${g.type}::${g.name}`,
...g,
beforeCount: 0,
afterCount: g.count,
countDiff: g.count,
beforeSize: 0,
afterSize: g.totalSize,
sizeDiff: g.totalSize,
}))
.sort((a, b) => b.sizeDiff - a.sizeDiff)
.slice(0, topN);
return {
summary: {
beforeNodes: beforeResult.nodeCount,
afterNodes: afterResult.nodeCount,
beforeSize: beforeResult.totalSize,
afterSize: afterResult.totalSize,
nodeDelta: afterResult.nodeCount - beforeResult.nodeCount,
sizeDelta: afterResult.totalSize - beforeResult.totalSize,
},
topBySize,
topByCount,
newObjectGroups: newGroups,
};
}
export function formatBytes(bytes: number): string {
if (Math.abs(bytes) < 1024) { return `${bytes} B`; }
if (Math.abs(bytes) < 1024 * 1024) { return `${(bytes / 1024).toFixed(1)} KB`; }
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
/**
* Print a comparison result to the console.
*/
function formatSigned(value: number, formatter: (v: number) => string): string {
return `${value >= 0 ? '+' : ''}${formatter(value)}`;
}
export function printComparison(result: ComparisonResult): void {
const s = result.summary;
console.log(`\nBefore: ${s.beforeNodes} nodes, ${formatBytes(s.beforeSize)}`);
console.log(`After: ${s.afterNodes} nodes, ${formatBytes(s.afterSize)}`);
console.log(`Delta: ${formatSigned(s.nodeDelta, String)} nodes, ${formatSigned(s.sizeDelta, formatBytes)}\n`);
console.log('=== TOP by SIZE increase ===');
for (const d of result.topBySize.slice(0, 30)) {
console.log(` ${d.key}: ${d.beforeCount} → ${d.afterCount} (${formatSigned(d.countDiff, String)}) | ${formatSigned(d.sizeDiff, formatBytes)}`);
}
console.log('\n=== TOP by COUNT increase ===');
for (const d of result.topByCount.slice(0, 30)) {
console.log(` ${d.key}: ${d.beforeCount} → ${d.afterCount} (${formatSigned(d.countDiff, String)}) | ${formatSigned(d.sizeDiff, formatBytes)}`);
}
}
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
/**
* Retainer Path Analysis
*
* Finds what keeps objects alive in the heap by tracing non-weak
* reverse edges from target objects to GC roots.
*/
import { type HeapGraph, type HeapNode } from './parseSnapshot.ts';
export interface RetainerPathOptions {
/** Maximum number of paths to find per target class. Default: 5. */
maxPaths?: number;
/** Maximum BFS depth. Default: 25. */
maxDepth?: number;
/** Maximum number of instances to attempt before giving up. Default: 200. */
maxAttempts?: number;
}
/**
* Find retainer paths for all instances of a named class.
* Skips weak edges so only genuine retainers are reported.
*
* @returns The number of paths found.
*/
export function findRetainerPaths(
graph: HeapGraph,
targetName: string,
options: RetainerPathOptions = {},
): number {
const { maxPaths = 5, maxDepth = 25, maxAttempts = 200 } = options;
// Find target nodes
const targets: number[] = [];
for (let i = 0; i < graph.nodes.length; i++) {
if (graph.nodes[i].name === targetName && graph.nodes[i].type === 'object') {
targets.push(i);
}
}
console.log(`Found ${targets.length} instances of ${targetName}`);
let pathsFound = 0;
let attempts = 0;
let unreachable = 0;
for (const targetIdx of targets) {
if (pathsFound >= maxPaths) { break; }
if (attempts >= maxAttempts) {
console.log(` (stopped after ${maxAttempts} attempts, ${unreachable} unreachable)`);
break;
}
attempts++;
const path = bfsToRoot(graph, targetIdx, maxDepth);
if (path) {
console.log(`\nPath #${pathsFound + 1} for ${targetName} (id:${graph.nodes[targetIdx].id}):`);
printPath(graph, path);
pathsFound++;
} else {
unreachable++;
}
}
if (pathsFound === 0 && unreachable > 0) {
console.log(` No retainer paths found (${unreachable} instances unreachable — likely pending GC)`);
}
return pathsFound;
}
/**
* Find ALL non-weak retainers of a specific node (by node index).
* Returns the immediate parent nodes that reference this node.
*/
export function findDirectRetainers(
graph: HeapGraph,
nodeIndex: number,
): { node: HeapNode; edgeName: string; edgeType: string }[] {
const incoming = graph.reverseEdges.get(nodeIndex) || [];
return incoming.map(edge => ({
node: graph.nodes[edge.fromNodeIndex],
edgeName: edge.edgeName,
edgeType: edge.edgeType,
}));
}
/**
* Find a node by its heap ID and optional name filter.
*/
export function findNodeById(graph: HeapGraph, id: number, name?: string): number {
for (let i = 0; i < graph.nodes.length; i++) {
if (graph.nodes[i].id === id && (!name || graph.nodes[i].name === name)) {
return i;
}
}
return -1;
}
/**
* Find all instances of a named class.
*/
export function findNodesByName(graph: HeapGraph, name: string, type = 'object'): HeapNode[] {
return graph.nodes.filter(n => n.name === name && n.type === type);
}
// ---- Internal ----
function bfsToRoot(graph: HeapGraph, startNi: number, maxDepth: number): number[] | null {
const visited = new Set<number>();
// Use parent pointers instead of copying full paths — much faster for large graphs
const parent = new Map<number, number>();
const queue: number[] = [startNi];
const depth = new Map<number, number>();
visited.add(startNi);
depth.set(startNi, 0);
let head = 0;
while (head < queue.length) {
const current = queue[head++];
const currentDepth = depth.get(current)!;
if (currentDepth > maxDepth) { continue; }
const node = graph.nodes[current];
if (node.type === 'synthetic' || current === 0) {
// Reconstruct path from parent pointers
const path: number[] = [];
let cur: number | undefined = current;
while (cur !== undefined) {
path.push(cur);
cur = parent.get(cur);
}
path.reverse();
return path;
}
const incoming = graph.reverseEdges.get(current) || [];
for (const edge of incoming) {
if (!visited.has(edge.fromNodeIndex)) {
visited.add(edge.fromNodeIndex);
parent.set(edge.fromNodeIndex, current);
depth.set(edge.fromNodeIndex, currentDepth + 1);
queue.push(edge.fromNodeIndex);
}
}
}
return null;
}
function printPath(graph: HeapGraph, path: number[]): void {
for (let idx = 0; idx < path.length; idx++) {
const n = graph.nodes[path[idx]];
let edgeLabel = '';
if (idx > 0) {
const prevNi = path[idx - 1];
const edges = graph.reverseEdges.get(prevNi) || [];
const edge = edges.find(e => e.fromNodeIndex === path[idx]);
edgeLabel = edge ? ` <--[${edge.edgeName}(${edge.edgeType})]-- ` : ' <-- ';
}
console.log(` ${edgeLabel}${n.type}::${n.name}(${n.id})`);
}
}
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
/**
* V8 Heap Snapshot Parser
*
* Parses .heapsnapshot files that are too large for JSON.parse by using
* Buffer-based section extraction. Provides a graph structure for
* retainer path analysis.
*/
import { readFileSync, statSync } from 'fs';
export interface SnapshotMeta {
node_fields: string[];
node_types: string[][];
edge_fields: string[];
edge_types: string[][];
}
export interface SnapshotData {
meta: SnapshotMeta;
nodes: number[];
edges: number[];
strings: string[];
}
export interface HeapNode {
type: string;
name: string;
id: number;
selfSize: number;
edgeCount: number;
nodeIndex: number;
}
export interface HeapEdge {
type: string;
name: string;
toNodeIndex: number;
}
export interface HeapGraph {
nodes: HeapNode[];
/** Forward edges per node index */
forwardEdges: HeapEdge[][];
/** Reverse edges per node index (non-weak only) */
reverseEdges: Map<number, { fromNodeIndex: number; edgeName: string; edgeType: string }[]>;
strings: string[];
}
/**
* Parse a V8 heap snapshot file.
* Uses Buffer-based extraction to handle files larger than V8's string limit.
*/
export function parseSnapshot(path: string): SnapshotData {
console.log(`Parsing ${path}...`);
const startTime = Date.now();
const stat = statSync(path);
console.log(` File size: ${(stat.size / 1024 / 1024).toFixed(0)}MB`);
const buf = readFileSync(path);
console.log(` Read in ${Date.now() - startTime}ms`);
// Parse meta
const metaKeyPos = buf.indexOf(Buffer.from('"meta"'));
if (metaKeyPos === -1) { throw new Error('meta section not found in snapshot'); }
const metaBraceStart = buf.indexOf(Buffer.from('{'), metaKeyPos);
if (metaBraceStart === -1) { throw new Error('meta section opening brace not found'); }
let depth = 0, metaBraceEnd = -1;
for (let i = metaBraceStart; i < buf.length; i++) {
if (buf[i] === 0x7B) { depth++; }
else if (buf[i] === 0x7D) { depth--; if (depth === 0) { metaBraceEnd = i + 1; break; } }
if (buf[i] === 0x22) { i++; while (i < buf.length) { if (buf[i] === 0x5C) { i++; } else if (buf[i] === 0x22) { break; } i++; } }
}
const meta: SnapshotMeta = JSON.parse(buf.subarray(metaBraceStart, metaBraceEnd).toString('utf8'));
console.log(` node_fields: ${meta.node_fields.join(', ')}`);
// Extract nodes array
const nodesKeyBuf = Buffer.from('"nodes":[');
const nodesPos = buf.indexOf(nodesKeyBuf);
if (nodesPos === -1) { throw new Error('nodes array not found in snapshot'); }
const nodesArrayStart = nodesPos + 8;
const nodesEnd = buf.indexOf(Buffer.from(']'), nodesArrayStart);
const nodes = buf.subarray(nodesArrayStart + 1, nodesEnd).toString('utf8').split(',').map(Number);
const nodeFieldCount = meta.node_fields.length;
console.log(` Parsed ${nodes.length / nodeFieldCount} nodes in ${Date.now() - startTime}ms`);
// Extract edges array
const edgesKeyBuf = Buffer.from('"edges":[');
const edgesPos = buf.indexOf(edgesKeyBuf);
if (edgesPos === -1) { throw new Error('edges array not found in snapshot'); }
const edgesArrayStart = edgesPos + 8;
const edgesEnd = buf.indexOf(Buffer.from(']'), edgesArrayStart);
const edges = buf.subarray(edgesArrayStart + 1, edgesEnd).toString('utf8').split(',').map(Number);
console.log(` Parsed ${edges.length / meta.edge_fields.length} edges in ${Date.now() - startTime}ms`);
// Extract strings array
const stringsKeyBuf = Buffer.from('"strings":[');
let stringsPos = -1;
for (let i = buf.length - 100; i >= 0; i--) {
if (buf[i] === 0x22 && buf.subarray(i, i + 11).equals(stringsKeyBuf)) {
stringsPos = i;
break;
}
}
if (stringsPos === -1) { throw new Error('strings array not found'); }
const stringsArrayStart = stringsPos + 10;
depth = 0;
let stringsEnd = -1;
for (let i = stringsArrayStart; i < buf.length; i++) {
if (buf[i] === 0x5B) { depth++; }
else if (buf[i] === 0x5D) { depth--; if (depth === 0) { stringsEnd = i + 1; break; } }
if (buf[i] === 0x22) { i++; while (i < buf.length) { if (buf[i] === 0x5C) { i++; } else if (buf[i] === 0x22) { break; } i++; } }
}
if (stringsEnd === -1) { throw new Error('strings array end not found'); }
const strings: string[] = JSON.parse(buf.subarray(stringsArrayStart, stringsEnd).toString('utf8'));
console.log(` Parsed ${strings.length} strings in ${Date.now() - startTime}ms`);
return { meta, nodes, edges, strings };
}
/**
* Build a graph structure from parsed snapshot data.
* Includes both forward edges (for traversal) and reverse edges (for retainer analysis).
* Reverse edges exclude weak references since they don't prevent GC.
*/
export function buildGraph(data: SnapshotData): HeapGraph {
const { meta, nodes, edges, strings } = data;
const nfc = meta.node_fields.length;
const efc = meta.edge_fields.length;
const nodeTypes = meta.node_types[0];
const edgeTypes = meta.edge_types[0];
const typeIdx = meta.node_fields.indexOf('type');
const nameIdx = meta.node_fields.indexOf('name');
const idIdx = meta.node_fields.indexOf('id');
const selfSizeIdx = meta.node_fields.indexOf('self_size');
const edgeCountIdx = meta.node_fields.indexOf('edge_count');
const eTypeIdx = meta.edge_fields.indexOf('type');
const eNameIdx = meta.edge_fields.indexOf('name_or_index');
const eToIdx = meta.edge_fields.indexOf('to_node');
const nodeCount = nodes.length / nfc;
const heapNodes: HeapNode[] = [];
for (let i = 0; i < nodes.length; i += nfc) {
heapNodes.push({
type: nodeTypes[nodes[i + typeIdx]],
name: strings[nodes[i + nameIdx]],
id: nodes[i + idIdx],
selfSize: nodes[i + selfSizeIdx],
edgeCount: nodes[i + edgeCountIdx],
nodeIndex: i / nfc,
});
}
// Build forward edges and reverse edges (non-weak)
const forwardEdges: HeapEdge[][] = new Array(nodeCount);
const reverseEdges = new Map<number, { fromNodeIndex: number; edgeName: string; edgeType: string }[]>();
let edgeOffset = 0;
for (let ni = 0; ni < nodeCount; ni++) {
const ec = heapNodes[ni].edgeCount;
const myEdges: HeapEdge[] = [];
for (let j = 0; j < ec; j++) {
const base = (edgeOffset + j) * efc;
const edgeType = edgeTypes[edges[base + eTypeIdx]];
const nameOrIndex = edges[base + eNameIdx];
const toNodeOffset = edges[base + eToIdx];
const toNodeIndex = toNodeOffset / nfc;
const edgeName = (edgeType === 'element' || edgeType === 'hidden')
? String(nameOrIndex)
: strings[nameOrIndex];
myEdges.push({ type: edgeType, name: edgeName, toNodeIndex });
// Build reverse edges, skipping weak refs
if (edgeType !== 'weak') {
if (!reverseEdges.has(toNodeIndex)) {
reverseEdges.set(toNodeIndex, []);
}
reverseEdges.get(toNodeIndex)!.push({ fromNodeIndex: ni, edgeName, edgeType });
}
}
forwardEdges[ni] = myEdges;
edgeOffset += ec;
}
return { nodes: heapNodes, forwardEdges, reverseEdges, strings };
}
/**
* Collect all node IDs from a snapshot for before/after comparison.
*/
export function collectNodeIds(data: SnapshotData): Set<number> {
const ids = new Set<number>();
const nfc = data.meta.node_fields.length;
const idIdx = data.meta.node_fields.indexOf('id');
for (let i = 0; i < data.nodes.length; i += nfc) {
ids.add(data.nodes[i + idIdx]);
}
return ids;
}
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { closeSync, openSync, readSync, statSync } from 'fs';
export const defaultTopLevelTokens = [
'"meta"',
'"nodes"',
'"edges"',
'"trace_function_infos"',
'"trace_tree"',
'"samples"',
'"locations"',
'"strings"'
];
export function formatBytes(bytes) {
if (Math.abs(bytes) < 1024) {
return `${bytes} B`;
}
if (Math.abs(bytes) < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
export function findTokenOffsets(path, tokens = defaultTopLevelTokens, options = {}) {
const stat = statSync(path);
const fd = openSync(path, 'r');
const chunkSize = options.chunkSize ?? 8 * 1024 * 1024;
const overlap = options.overlap ?? 256;
const found = new Map();
let previous = Buffer.alloc(0);
let position = 0;
try {
while (position < stat.size && found.size < tokens.length) {
const toRead = Math.min(chunkSize, stat.size - position);
const chunk = Buffer.allocUnsafe(toRead);
const bytesRead = readSync(fd, chunk, 0, toRead, position);
if (bytesRead <= 0) {
break;
}
const combined = Buffer.concat([previous, chunk.subarray(0, bytesRead)]);
for (const token of tokens) {
if (found.has(token)) {
continue;
}
const index = combined.indexOf(token);
if (index !== -1) {
found.set(token, position - previous.length + index);
}
}
previous = combined.subarray(Math.max(0, combined.length - overlap));
position += bytesRead;
}
} finally {
closeSync(fd);
}
return { size: stat.size, offsets: found };
}
export function readRange(path, start, length) {
const fd = openSync(path, 'r');
const buffer = Buffer.allocUnsafe(length);
let offset = 0;
try {
while (offset < length) {
const bytesRead = readSync(fd, buffer, offset, length - offset, start + offset);
if (bytesRead === 0) {
return buffer.subarray(0, offset);
}
offset += bytesRead;
}
return buffer;
} finally {
closeSync(fd);
}
}
export function parseMeta(path, options = {}) {
const maxBytes = options.maxBytes ?? 1024 * 1024;
const buffer = readRange(path, 0, maxBytes);
const metaPosition = buffer.indexOf(Buffer.from('"meta"'));
if (metaPosition === -1) {
throw new Error('Unable to find snapshot meta section');
}
const start = buffer.indexOf(Buffer.from('{'), metaPosition);
if (start === -1) {
throw new Error('Unable to find snapshot meta object start');
}
let depth = 0;
for (let i = start; i < buffer.length; i++) {
if (buffer[i] === 0x22) {
i++;
while (i < buffer.length) {
if (buffer[i] === 0x5c) {
i += 2;
continue;
}
if (buffer[i] === 0x22) {
break;
}
i++;
}
continue;
}
if (buffer[i] === 0x7b) {
depth++;
} else if (buffer[i] === 0x7d) {
depth--;
if (depth === 0) {
return JSON.parse(buffer.subarray(start, i + 1).toString('utf8'));
}
}
}
throw new Error(`Unable to parse snapshot meta within first ${formatBytes(maxBytes)}`);
}
export function findArrayStart(path, tokenOffset, options = {}) {
const windowSize = options.windowSize ?? 4096;
const buffer = readRange(path, tokenOffset, windowSize);
const bracket = buffer.indexOf(Buffer.from('['));
if (bracket === -1) {
throw new Error(`Unable to find array start near offset ${tokenOffset}`);
}
return tokenOffset + bracket + 1;
}
export function streamNumberArray(path, start, end, onNumber, options = {}) {
const fd = openSync(path, 'r');
const chunkSize = options.chunkSize ?? 16 * 1024 * 1024;
const buffer = Buffer.allocUnsafe(chunkSize);
let position = start;
let number = 0;
let inNumber = false;
let numberIndex = 0;
try {
while (position < end) {
const toRead = Math.min(chunkSize, end - position);
const bytesRead = readSync(fd, buffer, 0, toRead, position);
if (bytesRead <= 0) {
break;
}
for (let i = 0; i < bytesRead; i++) {
const code = buffer[i];
if (code >= 0x30 && code <= 0x39) {
number = number * 10 + code - 0x30;
inNumber = true;
} else if (inNumber) {
onNumber(number, numberIndex++);
number = 0;
inNumber = false;
if (code === 0x5d) {
return numberIndex;
}
} else if (code === 0x5d) {
return numberIndex;
}
}
position += bytesRead;
}
if (inNumber) {
onNumber(number, numberIndex++);
}
return numberIndex;
} finally {
closeSync(fd);
}
}
/**
* Streams fixed-size tuples from a number array.
*
* By default, the same mutable tuple array instance is reused for each callback
* invocation to avoid per-tuple allocations. Callers must not retain that array
* reference after onTuple returns unless options.copyTuple is enabled.
*/
export function streamNumberTuples(path, start, end, tupleSize, onTuple, options = {}) {
const tuple = new Array(tupleSize);
const copyTuple = options.copyTuple === true;
let tupleIndex = 0;
let fieldIndex = 0;
const numberCount = streamNumberArray(path, start, end, value => {
tuple[fieldIndex++] = value;
if (fieldIndex === tupleSize) {
onTuple(copyTuple ? tuple.slice() : tuple, tupleIndex++);
fieldIndex = 0;
}
}, options);
if (fieldIndex !== 0) {
throw new Error(`Number array ended with an incomplete tuple: ${fieldIndex}/${tupleSize}`);
}
return { numberCount, tupleCount: tupleIndex };
}
export function parseStrings(path, stringsTokenOffset, options = {}) {
const normalizedOptions = typeof options === 'number' ? { fileSize: options } : options;
const fileSize = normalizedOptions.fileSize ?? statSync(path).size;
const length = fileSize - stringsTokenOffset;
const maxBytes = normalizedOptions.maxBytes ?? 512 * 1024 * 1024;
if (length > maxBytes) {
throw new Error(`Refusing to parse ${formatBytes(length)} strings section into one Buffer. Pass a larger maxBytes value only if this is intentional.`);
}
const buffer = readRange(path, stringsTokenOffset, length);
const start = buffer.indexOf(Buffer.from('['));
if (start === -1) {
throw new Error(`Unable to find strings array near offset ${stringsTokenOffset}`);
}
let depth = 0;
for (let i = start; i < buffer.length; i++) {
if (buffer[i] === 0x22) {
i++;
while (i < buffer.length) {
if (buffer[i] === 0x5c) {
i += 2;
continue;
}
if (buffer[i] === 0x22) {
break;
}
i++;
}
continue;
}
if (buffer[i] === 0x5b) {
depth++;
} else if (buffer[i] === 0x5d) {
depth--;
if (depth === 0) {
return JSON.parse(buffer.subarray(start, i + 1).toString('utf8'));
}
}
}
throw new Error('Unable to parse strings array');
}
*
!README.md
!.gitignore
This folder is a scratchpad for heap snapshot investigation scripts.
Files here are gitignored — write freely.
#
Organization:
Put each investigation in a dated subfolder:
scratchpad/2026-04-09-chat-model-retainers/analyze.mjs
scratchpad/2026-04-09-chat-model-retainers/findings.md
#
Each subfolder should include a findings.md documenting all ideas
considered, decisions made, and before/after measurements.
#
Example usage:
node --max-old-space-size=16384 scratchpad/2026-04-09-chat-model-retainers/analyze.mjs
#
Import helpers like:
import { parseSnapshot, buildGraph } from '../helpers/parseSnapshot.ts';
import { compareSnapshots, printComparison } from '../helpers/compareSnapshots.ts';
import { findRetainerPaths } from '../helpers/findRetainers.ts';
Related skills
FAQ
What does heap-snapshot-analysis do?
Analyze V8 heap snapshots to investigate memory leaks and retention issues. Use when given .heapsnapshot files, asked to compare before/after snapshots, asked to find what retains objects...
When should I use heap-snapshot-analysis?
Invoke when Analyze V8 heap snapshots to investigate memory leaks and retention issues. Use when given .heapsnapshot files, asked to compare before/afte.
Is heap-snapshot-analysis safe to install?
Review the Security Audits panel on this page before installing in production.