Debug Memory Leaks in a Running Node.js Process
Memory leaks in Node.js can degrade performance, increase hosting costs, and cause application crashes over time due to Out-Of-Memory (OOM) errors. This article provides a practical guide on how to identify, diagnose, and debug memory leaks in active, running Node.js processes using built-in tools, heap snapshots, and diagnostic workflows.
1. Identifying the Presence of a Memory Leak
Before debugging, you must confirm that a memory leak actually exists. A healthy Node.js application will see its memory usage rise under load and fall once garbage collection (GC) runs. A memory leak is characterized by a “sawtooth” pattern where the baseline memory consumption continuously increases over time, even after idle periods.
To monitor this, track the following metrics using system utilities
(like top or htop) or Application Performance
Monitoring (APM) tools: * Resident Set Size (RSS): The
total memory allocated for the process in RAM. * V8 Heap
Used: The memory actually occupied by JavaScript objects.
If the V8 heap used steadily climbs and never returns to its starting baseline after a garbage collection cycle, your process is leaking memory.
2. Generating Heap Snapshots Programmatically
The most effective way to find the source of a leak is by analyzing
heap snapshots, which show all active JavaScript objects and their
references. While you can start Node.js with the --inspect
flag to connect debugger tools, doing so on a live production server is
often impractical or insecure.
Instead, you can generate heap snapshots programmatically within your
running application. Node.js provides a built-in v8 module
that writes a snapshot directly to disk:
const v8 = require('v8');
const fs = require('fs');
// Call this function via an HTTP endpoint, a signal handler, or a timer
function triggerHeapSnapshot() {
const filename = `./snapshot-${Date.now()}.heapsnapshot`;
const stream = v8.getHeapSnapshot();
const fileStream = fs.createWriteStream(filename);
stream.pipe(fileStream);
fileStream.on('finish', () => {
console.log(`Snapshot written to ${filename}`);
});
}Alternatively, you can start your Node.js process with the
--heapsnapshot-signal flag:
node --heapsnapshot-signal=SIGUSR2 index.jsSending a SIGUSR2 signal to the running process
(kill -USR2 <pid>) will instruct Node.js to write a
heap snapshot to the current working directory without interrupting the
service.
3. Analyzing the Snapshots with Chrome DevTools
To find the leak, you need at least two heap snapshots: one taken shortly after the application starts (the baseline) and another taken after the application has handled several requests and memory usage has increased.
- Open Google Chrome and navigate to
chrome://inspect. - Click Open dedicated DevTools for Node.
- Go to the Memory tab, right-click on the left
sidebar, and select Load to import your
.heapsnapshotfiles. - Select the second (larger) snapshot.
- Change the perspective drop-down menu from Summary to Comparison, and select the first snapshot as the target for comparison.
Look closely at the Delta and Size Delta columns. Sort by these columns to identify which constructor functions have grown the most in volume.
4. Tracking Down the Leaking Code
When analyzing the comparison view, pay attention to these key indicators:
- Constructor: The type of object (e.g.,
closure,array,string, or custom class). - Distance: The shortest path of references from the GC root. A lower distance means the object is held close to the root application state.
- Shallow Size: The memory held by the object itself.
- Retained Size: The memory freed if this object and its descendants are deleted. Focus on objects with a high Retained Size.
Expand the suspected leaking objects to view the
Retainer tree at the bottom of the screen. This tree
shows which variables or parent objects are holding onto the reference,
preventing the garbage collector from freeing the memory. Common
culprits include: * Unused event listeners that were never removed via
.removeListener(). * Active intervals or timeouts
(setInterval) referencing out-of-scope variables. * Global
variables or long-lived caches that grow indefinitely without expiration
limits.