When a Node.js process terminates with FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory, the application has exceeded its configured V8 memory ceiling (--max-old-space-size).
Because JavaScript employs automatic garbage collection, developers often assume memory leaks are impossible. In reality, memory leaks in garbage-collected runtimes occur whenever references to short-lived objects are unintentionally held by long-lived root objects.
1. The Hidden Closure Scope Leak
One of the most deceptive memory leak patterns in V8 stems from shared closure scopes.
let theThing = null;
function replaceThing() {
const originalThing = theThing;
// unused() references originalThing in its lexical scope
const unused = function () {
if (originalThing) console.log("hi");
};
// theThing is reassigned an object with a closure (someMethod)
theThing = {
longStr: new Array(1000000).join("*"),
someMethod: function () {
console.log("active");
}
};
}
// Every 10ms, replaceThing is called:
setInterval(replaceThing, 10);
Why V8 Leaks Memory Here
Because unused and someMethod share the same parent lexical closure context in V8, someMethod keeps the entire closure scope alive. This holds a reference to originalThing, which in turn holds the previous theThing. This creates an unbroken, growing linked list of megabyte-sized strings that the garbage collector cannot reclaim.
2. Unregistered EventEmitter Listeners
Another frequent memory leak source is registering listeners on long-lived emitter singletons (such as process, database pools, or global event buses) inside request handlers without removing them upon request completion:
app.get('/data-stream', (req, res) => {
const onMetric = (data) => {
res.write(JSON.stringify(data));
};
// LEAK: Global emitter retains reference to onMetric closure + req/res objects
globalTelemetryBus.on('tick', onMetric);
req.on('close', () => {
// FIX: Must explicitly remove listener when connection closes!
globalTelemetryBus.removeListener('tick', onMetric);
res.end();
});
});
3. Capturing Differential Heap Snapshots
To isolate the root retainer in production, capture two heap snapshots: one immediately after process warmup (Snapshot A), and one after processing 1,000 requests (Snapshot B).
# Capture heap snapshot via Node inspector or v8 module
node --inspect server.js
In Chrome DevTools:
- Open Memory Panel → Load both Snapshot A and Snapshot B.
- Switch view mode to “Comparison”.
- Sort by "# Alloc" or “Size Delta”.
- Expand the suspect class and inspect the “Retainers” tree at the bottom of the window to identify the root reference maintaining the retention chain.
Through methodical comparison of heap deltas, memory growth can be systematically tracked to its exact retaining closure.