Node.js 76 🟢 Memory Leak Detection, Heap Profiling, and Heap Snapshots
A memory leak in Node.js is not a crash. It is a slow, silent bleed. The process starts at 80 MB, climbs to 200 MB over a few hours, then 500 MB overnight, and eventually the container hits its memory limit and is killed by the OOM killer. The application “works” until it doesn’t, and by then the logs show nothing useful. The V8 heap is the culprit, and the tools to find the leak are built into the runtime—but they require a different debugging discipline than the breakpoint-and-step approach from the previous chapter.
Heap snapshots are the primary diagnostic tool. A snapshot is a complete record of every object in the V8 heap at a moment in time, including its size, its type, and the references that keep it alive. Comparing two snapshots—one taken before a suspected leak and one after—reveals exactly which objects were allocated and never released. The Node.js documentation is explicit about the cost: taking a snapshot requires roughly twice the heap size in additional memory, and it blocks the event loop for the duration of the capture . In development, this is a minor inconvenience. In production, it can crash the process it is trying to diagnose.
Key point: A memory leak is diagnosed by comparing two heap snapshots taken at different points in the application’s lifecycle; the Chrome DevTools Memory panel’s Comparison view shows which objects grew in count and retained size, and the retainer chain for each growing object reveals the reference that is preventing garbage collection.
Why heap snapshots are the definitive tool
The process.memoryUsage ceiling. process.memoryUsage() tells you that the heap is growing. It reports heapUsed, heapTotal, rss, and external in bytes. A monitoring loop that logs these values over time confirms that a leak exists . But it does not tell you what is leaking. The heap is a black box; memoryUsage only reports the size of the box. The snapshot opens the box and shows every object inside.
The three-snapshot technique. A single snapshot is a static image. It shows the heap at one moment, but it cannot distinguish between objects that are supposed to be there and objects that are accumulating. The comparison technique requires at least two snapshots. The Node.js documentation recommends a specific sequence: let the application finish bootstrapping, use the suspect functionality once (to allocate the initial objects), take the first snapshot, use the functionality for a while longer, take the second snapshot. The difference between them should contain mostly the leaking objects . A third snapshot can confirm whether the growth is consistent or a one-time allocation.
The retainer chain. When a snapshot shows a growing object—say, an array of user sessions that keeps getting larger—the next question is why it is not being garbage collected. The Retainers panel in DevTools shows the chain of references from the GC root to the object. The root might be a global variable, a closure captured by a long-lived function, or an event listener that was never removed. The retainer chain identifies the exact reference that is keeping the object alive, which is the reference that must be broken to fix the leak .
The snapshot cost problem. v8.writeHeapSnapshot() is synchronous and blocks the event loop. It also allocates memory for the snapshot itself, roughly equal to the current heap size. On a production server with a 1 GB heap, taking a snapshot can push memory usage to 2 GB, which may trigger the OOM killer before the snapshot finishes . This is why the Node.js documentation warns that snapshots in production should only be taken from a process that can crash without affecting availability, or from a replica that is not serving traffic .
a. Taking snapshots: three methods
The most direct method is v8.writeHeapSnapshot(). It writes a .heapsnapshot file to disk and returns the filename. This is the method for adding a diagnostic endpoint to an application or triggering a snapshot from a signal handler.
const v8 = require('node:v8');
const filename = v8.writeHeapSnapshot();
console.log(`Snapshot written to ${filename}`);
The filename defaults to a pattern that includes the date, time, and process ID. The filename argument can override it. The v8.getHeapSnapshot() method returns a readable stream instead of writing to disk, which is useful when the snapshot needs to be sent over HTTP rather than saved locally .
The second method is the inspector protocol. When the process is started with --inspect, Chrome DevTools can take snapshots from the Memory tab. This is the interactive method for development. The DevTools Memory panel also supports allocation instrumentation on a timeline, which records allocations as they happen rather than taking a point-in-time snapshot .
The third method is the signal-based trigger, available in Node.js 12.4.0 and later. Starting the process with --heapsnapshot-signal=SIGUSR2 tells the runtime to take a snapshot when it receives that signal. Sending kill -SIGUSR2 <pid> from the shell produces a snapshot file in the working directory .
node --heapsnapshot-signal=SIGUSR2 app.js
kill -SIGUSR2 12345
The signal method is the least invasive for production because it does not require an inspector connection or a code change. The snapshot is written to disk, and the process continues running. The cost is the same—the event loop blocks during the write—but the trigger is a shell command rather than a DevTools session.
b. Reading the snapshot in Chrome DevTools
The .heapsnapshot file is JSON, but it is not meant to be read by hand. Chrome DevTools parses it and presents it in the Memory tab. The workflow is: open DevTools, go to Memory, click “Load,” select the snapshot file. The snapshot appears in the left sidebar.
The default view is Summary, which groups objects by constructor. The columns are Shallow Size (the memory used by the object itself), Retained Size (the memory that would be freed if the object were garbage collected), and Distance (the number of hops from the GC root) . Sorting by Retained Size shows which objects are holding the most memory. A (string) or (array) entry with a large retained size is a common suspect.
The Comparison view is where leaks are found. Load both snapshots, select the newer one, and switch the dropdown to Comparison. The # Delta column shows the net change in object count between the two snapshots. Sort by this column, and look for constructors with a large positive delta. If UserSession grew from 1,000 to 50,000 between snapshots, the application is creating sessions without releasing them .
The Containment view shows the full object reference graph. Clicking an object reveals its retainers—the objects that hold references to it. Following the retainer chain upward leads to the GC root and the code that is keeping the object alive . The Retainers panel at the bottom of the snapshot view shows this chain directly.
The Distance column is a quick heuristic. An object with a high distance is deeply buried in the reference graph, which often means it is held by a chain of closures or nested objects. An object with a distance of 1 or 2 is held directly by a global or a top-level reference, which is easier to find and fix.
c. Common leak patterns and the snapshot evidence
The snapshot does not tell you what the code is doing wrong. It tells you which objects are growing. The interpretation requires knowing the common patterns.
Event listeners that are never removed. A class that subscribes to an EventEmitter in its constructor but never unsubscribes in a cleanup method will accumulate listeners if instances are created repeatedly. The snapshot shows a growing number of listener objects, and the retainer chain points to the EventEmitter‘s internal listener array .
Unbounded caches. A plain object used as a cache (const cache = {}) grows without limit. The snapshot shows a growing number of entries under the cache object, with the retainer chain pointing to the module-level variable. The fix is an LRU cache with a maximum size, or a Map with periodic eviction .
Closures holding large objects. A function returned from a factory captures variables from its enclosing scope. If the closure is stored somewhere long-lived, the captured objects cannot be freed. The snapshot shows the large object with a retainer chain that goes through the closure’s context. The fix is to null out the reference after use or restructure the closure to capture only what it needs .
Timers that are never cleared. setInterval creates a timer that holds a reference to its callback. If the interval is not cleared when the associated object is no longer needed, the callback and everything it closes over remain alive. The snapshot shows the timer object with a retainer chain to the callback and its captured scope .
The snapshot evidence for all of these is the same: a growing object count, a large retained size, and a retainer chain that points to a specific module or class. The fix is always to break the reference—remove the listener, evict the cache entry, null the closure variable, or clear the timer.
Complete Example Session
The following session builds an Express application with a deliberate leak, takes two snapshots, and uses the Comparison view to identify the growing object.
// ============================================
// PART 1: THE APPLICATION WITH A LEAK
// ============================================
// app.js
const express = require('express');
const app = express();
// Leak: unbounded array that grows on every request
const requestLog = [];
app.get('/leak', (req, res) => {
// This object is never released
requestLog.push({
timestamp: Date.now(),
payload: 'x'.repeat(1024 * 100), // 100 KB per entry
});
res.json({ entries: requestLog.length });
});
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
app.listen(3000, () => console.log('Server on :3000'));
// ============================================
// PART 2: START WITH SNAPSHOT SIGNAL
// ============================================
// node --heapsnapshot-signal=SIGUSR2 app.js
// ============================================
// PART 3: BASELINE SNAPSHOT
// ============================================
// Before sending any requests to /leak
// kill -SIGUSR2 <pid>
// Snapshot 1: baseline.heapsnapshot
// ============================================
// PART 4: GENERATE LOAD
// ============================================
// curl http://localhost:3000/leak (repeated 50 times)
// for i in {1..50}; do curl -s http://localhost:3000/leak > /dev/null; done
// ============================================
// PART 5: SECOND SNAPSHOT
// ============================================
// kill -SIGUSR2 <pid>
// Snapshot 2: after-load.heapsnapshot
// ============================================
// PART 6: COMPARE IN DEVTOOLS
// ============================================
// Open chrome://inspect or DevTools Memory tab
// Load both snapshots
// Select after-load.heapsnapshot
// Switch view to Comparison
// Select baseline.heapsnapshot as baseline
// Sort by # Delta
// ============================================
// PART 7: IDENTIFY THE LEAK
// ============================================
// The Comparison view shows:
// Object: (array) or Object
// # Delta: +50
// Retained Size: ~5 MB
// Click the object to see the retainer chain:
// Object → requestLog (module scope) → GC root
// The retainer chain confirms requestLog is holding the entries
// ============================================
// PART 8: THE FIX
// ============================================
// Replace the unbounded array with a bounded structure:
const MAX_ENTRIES = 100;
const requestLog = [];
app.get('/leak', (req, res) => {
requestLog.push({ timestamp: Date.now(), payload: 'x'.repeat(1024 * 100) });
if (requestLog.length > MAX_ENTRIES) {
requestLog.shift(); // evict oldest
}
res.json({ entries: requestLog.length });
});
The eight parts cover the leaking application, starting with the signal flag, taking the baseline snapshot, generating load, taking the second snapshot, comparing in DevTools, identifying the retainer chain, and the fix. What the session demonstrates is that the snapshot does not require code changes or a debugger connection—the signal triggers it, and the comparison view shows exactly which object grew and what is holding it.
Quick Reference
Snapshot Methods
| Method | Command | Use Case |
|---|---|---|
v8.writeHeapSnapshot() | Code call | Diagnostic endpoint |
| Inspector | --inspect + DevTools | Development |
| Signal | --heapsnapshot-signal=SIGUSR2 | Production |
Snapshot Cost
| Cost | Detail |
|---|---|
| Memory | ~2x heap size |
| Blocking | Synchronous, blocks event loop |
| Risk | OOM killer during capture |
DevTools Views
| View | Purpose |
|---|---|
| Summary | Objects grouped by constructor |
| Comparison | Diff between two snapshots |
| Containment | Full reference graph |
| Retainers | Chain from object to GC root |
Key Columns
| Column | Meaning |
|---|---|
| Shallow Size | Memory held by the object itself |
| Retained Size | Memory freed if object is GC’d |
| Distance | Hops from GC root |
| # Delta | Net change between snapshots |
Common Leak Patterns
| Pattern | Snapshot Evidence |
|---|---|
| Unremoved listeners | Growing listener count, retainer to EventEmitter |
| Unbounded cache | Growing object count, retainer to module variable |
| Closure capture | Large retained size, retainer through closure context |
| Uncleared timer | Timer object with callback scope retained |
Best Practices
✅ Do This:
node --heapsnapshot-signal=SIGUSR2 app.js // ✅ production-safe trigger
// Take baseline, generate load, take second snapshot // ✅ three-snapshot technique
// Sort by # Delta in Comparison view // ✅ find growing objects
// Follow retainer chain to the reference // ✅ identify the fix
❌ Don’t Do This:
// take snapshot on live production without replica // ❌ may OOM
// take one snapshot and try to find the leak // ❌ no baseline
// sort by Shallow Size only // ❌ misses retained memory
// ignore high Distance values // ❌ deeply buried references
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| OOM during snapshot | Snapshot doubles heap | Take on replica or low-memory load |
| No leak visible in diff | Snapshots too close together | Generate more load between them |
| Wrong object identified | Sorted by shallow size | Sort by Retained Size and # Delta |
| Retainer chain confusing | Deep reference graph | Follow the chain upward from the object |
| Snapshot file too large | Large heap | Use allocation timeline instead |
Real-World Examples
1. Programmatic snapshot
const v8 = require('node:v8');
v8.writeHeapSnapshot('/tmp/snapshot.heapsnapshot');
2. Signal trigger
node --heapsnapshot-signal=SIGUSR2 app.js
kill -SIGUSR2 12345
3. Inspector snapshot
node --inspect app.js
chrome://inspect → Memory → Take snapshot
4. Memory monitor
setInterval(() => {
const used = process.memoryUsage().heapUsed / 1024 / 1024;
if (used > 500) console.warn(`Heap: ${used.toFixed(1)} MB`);
}, 30000);
5. LRU cache fix
const cache = new Map();
if (cache.size >= 1000) cache.delete(cache.keys().next().value);
cache.set(key, value);
6. Remove listener fix
emitter.on('event', handler);
// later
emitter.removeListener('event', handler);
7. Clear timer fix
const timer = setInterval(fn, 1000);
clearInterval(timer);
8. Three-snapshot diff
Snapshot 1 → load → Snapshot 2 → load → Snapshot 3
Compare Snapshot 3 vs Snapshot 1
9. Sort by delta
Comparison view → click # Delta column header
10. Retainer chain
Click object → Retainers panel → follow chain to GC root
Visual
Snapshot Comparison Workflow
┌──────────────────────────────────────────────┐
│ Baseline snapshot │
│ │ │
│ ▼ │
│ Generate load (requests, operations) │
│ │ │
│ ▼ │
│ Second snapshot │
│ │ │
│ ▼ │
│ DevTools → Comparison view │
│ │ │
│ ▼ │
│ Sort by # Delta │
│ │ │
│ ▼ │
│ Growing object identified │
│ │ │
│ ▼ │
│ Retainer chain → reference to fix │
└──────────────────────────────────────────────┘
Retainer Chain
┌──────────────────────────────────────────────┐
│ GC Root │
│ │ │
│ ▼ │
│ module scope (requestLog) │
│ │ │
│ ▼ │
│ Array │
│ │ │
│ ▼ │
│ Object { timestamp, payload } │
│ │
│ The reference from module scope to Array │
│ is why the objects cannot be freed. │
└──────────────────────────────────────────────┘
Snapshot Cost
┌──────────────────────────────────────────────┐
│ Heap: 500 MB │
│ │ │
│ ▼ │
│ Snapshot capture │
│ - allocates ~500 MB for snapshot │
│ - blocks event loop │
│ │ │
│ ▼ │
│ Peak memory: ~1 GB │
│ Risk: OOM killer │
└──────────────────────────────────────────────┘
Common Leak Patterns
┌──────────────────────────────────────────────┐
│ EVENT LISTENER │
│ emitter.on(...) without removeListener │
│ → growing listener array │
│ │
│ CACHE │
│ const cache = {}; cache[key] = value │
│ → growing object │
│ │
│ CLOSURE │
│ function outer() { const big = ...; │
│ return () => big.length; } │
│ → big retained by closure │
│ │
│ TIMER │
│ setInterval(callback, 1000) without clear │
│ → callback and scope retained │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Primary tool | Heap snapshot |
| Snapshot cost | ~2x heap, blocks event loop |
| Programmatic | v8.writeHeapSnapshot() |
| Signal trigger | --heapsnapshot-signal=SIGUSR2 |
| Inspector | --inspect + DevTools Memory |
| Comparison view | Diff between snapshots |
| Key column | # Delta (object count growth) |
| Retainer chain | Path from object to GC root |
| Common leaks | Listeners, caches, closures, timers |
Key takeaways:
- A heap snapshot is a complete record of every object in the V8 heap. It shows the object’s size, type, and the references that keep it alive. A single snapshot is a static image; a comparison of two snapshots reveals what is growing .
- Taking a snapshot is expensive. It allocates memory roughly equal to the heap size and blocks the event loop. In production, trigger it from a replica or a process that can crash without affecting availability .
- The signal-based trigger is the production method.
--heapsnapshot-signal=SIGUSR2allows a snapshot to be taken from the shell without an inspector connection or a code change . - The Comparison view is where leaks are found. Sort by
# Deltato see which object types grew between snapshots. A large positive delta for a specific constructor is the leak suspect . - The retainer chain identifies the fix. Clicking a growing object shows the reference chain to the GC root. The reference at the top of the chain—a module variable, a closure, an event listener—is the one that must be broken .
- The three-snapshot technique confirms consistency. A baseline, a load phase, and a second snapshot show whether the growth is a one-time allocation or a continuous leak .
- Common leaks have recognizable patterns. Unremoved event listeners, unbounded caches, closures capturing large objects, and uncleared timers are the most frequent causes. The snapshot evidence for each is a growing object count with a retainer chain pointing to the specific reference .
Remember: A memory leak is not a logic error that a breakpoint can catch. It is an accumulation that only becomes visible over time. The heap snapshot is the tool that makes the invisible visible—it shows every object that the garbage collector cannot free and the chain of references that keeps it alive. The workflow is always the same: take a baseline, generate load, take a second snapshot, compare, sort by delta, and follow the retainer chain. The fix is always to break the reference. The cost is always the snapshot itself, which is why production snapshots require a process that can afford to be blocked or killed. The leak does not announce itself. The snapshot is how you find it.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!