loop-detective started as a CPU profiler. It told you which functions were blocking the event loop. Then we added I/O tracking — which network calls were slow. But there was always a gap in the story.
The analyzer would detect gc-pressure — garbage collection consuming 8% of CPU time — and suggest "Reduce object allocations. Reuse buffers. Check for memory leaks." Good advice. But then what? The user knows GC is a problem, but has no way to see what's actually in memory. They'd have to switch to a completely different tool, reconnect to the process, and start over.
With v2.1.0, loop-detective can now capture heap snapshots and track memory usage. The diagnostic workflow stays in one tool, from "something is slow" to "here's the object causing the memory leak."
The Two New Capabilities
--heap-stats: The Quick Check
loop-detective 12345 --heap-stats -d 30
This samples process.memoryUsage() before and after the profiling period:
Memory (before profiling)
RSS: 85.2MB Heap: 42.1MB / 65.0MB External: 1.2MB
Memory (after profiling)
RSS: 92.8MB Heap: 58.3MB / 75.0MB External: 1.2MB
Memory delta
Heap: +16.2MB RSS: +7.6MB
Four numbers tell you a lot:
- RSS (Resident Set Size): total memory the OS has allocated to the process
- Heap Used / Heap Total: V8's JavaScript heap — where your objects live
- External: memory used by C++ objects bound to JavaScript (Buffers, etc.)
The delta is color-coded: green for <1MB growth (normal), yellow for 1-10MB (worth investigating), red for >10MB (likely a leak or large allocation during the profiling window).
This is the "should I worry?" check. If heap grew 16MB during a 30-second profile, something is allocating a lot of objects. If it grew 0.2MB, memory is probably fine and the performance issue is elsewhere.
--heap-snapshot: The Deep Dive
loop-detective 12345 --heap-snapshot ./heap.heapsnapshot
This captures a full V8 heap snapshot — a complete inventory of every JavaScript object in memory, their sizes, and their reference chains. The .heapsnapshot file can be loaded in Chrome DevTools:
- Open Chrome DevTools → Memory tab
- Click "Load" and select the file
- Explore:
- Summary view: objects grouped by constructor, sorted by retained size
- Comparison view: diff two snapshots to find what grew
- Containment view: see the reference chain from GC roots to any object
- Statistics: pie chart of memory by type
The snapshot is captured after CPU profiling completes, so you get both the CPU analysis and the memory snapshot from the same session.
How It Works Under the Hood
Heap Stats
The simplest possible implementation. We use Runtime.evaluate to call process.memoryUsage() in the target process:
async getHeapStats() {
const result = await this.inspector.send('Runtime.evaluate', {
expression: `(function(){
const m = process.memoryUsage();
return {
rss: m.rss,
heapTotal: m.heapTotal,
heapUsed: m.heapUsed,
external: m.external,
arrayBuffers: m.arrayBuffers || 0
};
})()`,
returnByValue: true,
});
return result.result?.value || null;
}
We call this twice — once before profiling starts, once after it ends — and emit both values:
async _singleRun() {
let heapBefore = null;
try { heapBefore = await this.getHeapStats(); } catch {}
// ... profiling happens here ...
let heapAfter = null;
try { heapAfter = await this.getHeapStats(); } catch {}
if (heapBefore || heapAfter) {
this.emit('heapStats', { before: heapBefore, after: heapAfter });
}
}
Both calls are wrapped in try/catch because heap stats are non-essential — if they fail (target exited, inspector disconnected), the CPU profile and I/O data are still valid.
Heap Snapshot
The CDP HeapProfiler domain handles snapshot capture. The snapshot is streamed in chunks via events:
async captureHeapSnapshot() {
let chunks = [];
const onChunk = (msg) => {
if (msg.method === 'HeapProfiler.addHeapSnapshotChunk') {
chunks.push(msg.params.chunk);
}
};
this.inspector.on('event', onChunk);
try {
await this.inspector.send('HeapProfiler.enable');
await this.inspector.send('HeapProfiler.takeHeapSnapshot', {
reportProgress: false
});
await this.inspector.send('HeapProfiler.disable');
} finally {
this.inspector.removeListener('event', onChunk);
}
return chunks.join('');
}
The V8 heap snapshot format is JSON, but it can be large — 10MB to 200MB+ depending on heap size. The CDP protocol streams it in chunks to avoid sending one massive message. We collect all chunks and join them into the final string.
A few important details:
-
reportProgress: false— we skip progress events to keep the implementation simple. For very large heaps, you could listen forHeapProfiler.reportHeapSnapshotProgressto show a progress bar. - The snapshot is captured after CPU profiling, not during. Taking a heap snapshot pauses the target process briefly (it needs to walk the entire object graph), so we don't want it interfering with the CPU profile.
- The
finallyblock ensures we remove the event listener even if the snapshot fails.
The Diagnostic Workflow
Here's how the pieces fit together in a real debugging session:
Step 1: Quick diagnosis
loop-detective 12345 -d 10
The report shows gc-pressure — GC is consuming 8% of CPU. The event loop isn't blocked by user code, but GC pauses are adding latency.
Step 2: Confirm with memory stats
loop-detective 12345 --heap-stats -d 30
Memory delta
Heap: +24.3MB RSS: +18.1MB
Red flag. The heap grew 24MB in 30 seconds. Something is allocating objects faster than GC can collect them.
Step 3: Capture the evidence
loop-detective 12345 --heap-snapshot ./before-fix.heapsnapshot --heap-stats
Open before-fix.heapsnapshot in Chrome DevTools. The Summary view shows 500,000 Object instances retained by a Map in /app/cache.js. The cache has no eviction policy — it grows forever.
Step 4: Fix and verify
After adding an LRU eviction policy to the cache:
loop-detective 12345 --heap-stats -d 30
Memory delta
Heap: +0.8MB RSS: +0.3MB
Green. Memory is stable. The gc-pressure pattern is gone from the CPU profile.
When to Use Each
| Situation | Use |
|---|---|
| Quick health check | --heap-stats |
Analyzer reports gc-pressure
|
--heap-stats first, then --heap-snapshot if growth is high |
| Suspected memory leak |
--heap-snapshot (capture two snapshots minutes apart, compare in DevTools) |
| Full diagnostic session | --heap-stats --save-profile ./cpu.cpuprofile --html report.html |
| Memory-only investigation |
--heap-snapshot ./heap.heapsnapshot --no-io -d 1 (minimal profiling, just get the snapshot) |
Performance Impact
-
--heap-stats: negligible. Twoprocess.memoryUsage()calls, each taking <1ms. -
--heap-snapshot: moderate. The V8 heap walker pauses the target process while it traverses the object graph. For a 100MB heap, this takes 1-5 seconds. For a 1GB heap, it could take 10-30 seconds. The process is unresponsive during this time.
This is why the snapshot is captured after profiling, not during. And it's why it's opt-in (--heap-snapshot) rather than default.
Programmatic API
For custom tooling:
const { Detective } = require('node-loop-detective');
const fs = require('fs');
const detective = new Detective({ pid: 12345, duration: 10000 });
// Memory stats before/after profiling
detective.on('heapStats', (data) => {
const growth = data.after.heapUsed - data.before.heapUsed;
if (growth > 50 * 1024 * 1024) {
alert('Heap grew ' + (growth / 1024 / 1024).toFixed(0) + 'MB during profiling!');
}
});
detective.on('profile', async (analysis) => {
// If GC pressure detected, auto-capture heap snapshot
const hasGcPressure = analysis.blockingPatterns.some(p => p.type === 'gc-pressure');
if (hasGcPressure) {
const snapshot = await detective.captureHeapSnapshot();
fs.writeFileSync('auto-heap-' + Date.now() + '.heapsnapshot', snapshot);
}
});
await detective.start();
This pattern — auto-capture a heap snapshot when GC pressure is detected — is particularly powerful for automated monitoring. You get the snapshot exactly when it matters, without human intervention.
What's Next
The heap snapshot is a point-in-time view. It tells you what's in memory right now, but not how it got there. Future improvements could include:
-
Allocation tracking via
HeapProfiler.startTrackingHeapObjects— shows which functions are allocating the most objects -
Memory timeline — sample
process.memoryUsage()every second during profiling and show a trend line in the HTML report - Automatic leak detection — compare heap stats at the start and end of each watch cycle, flag monotonic growth
For now, the combination of --heap-stats (quick check) and --heap-snapshot (deep dive) covers the most common memory debugging workflow.
Try It
npm install -g node-loop-detective@2.1.0
# Quick memory check alongside CPU profiling
loop-detective <pid> --heap-stats
# Full memory diagnosis
loop-detective <pid> --heap-stats --heap-snapshot ./heap.heapsnapshot
# Minimal: just grab the snapshot
loop-detective <pid> --heap-snapshot ./heap.heapsnapshot --no-io -d 1
Source: github.com/iwtxokhtd83/node-loop-detective
CPU tells you what's slow. I/O tells you what's waiting. Memory tells you what's accumulating. Now you have all three in one tool.
Top comments (0)