DEV Community

Bill Tu
Bill Tu

Posted on

Completing the Picture: Adding Memory Diagnostics to a CPU Profiler

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Open Chrome DevTools → Memory tab
  2. Click "Load" and select the file
  3. 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;
}
Enter fullscreen mode Exit fullscreen mode

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 });
  }
}
Enter fullscreen mode Exit fullscreen mode

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('');
}
Enter fullscreen mode Exit fullscreen mode

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 for HeapProfiler.reportHeapSnapshotProgress to 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 finally block 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  Memory delta
    Heap: +24.3MB  RSS: +18.1MB
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  Memory delta
    Heap: +0.8MB  RSS: +0.3MB
Enter fullscreen mode Exit fullscreen mode

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. Two process.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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)