Node.js memory leaks cause your application's memory usage to grow continuously until it crashes with an out-of-memory error or gets killed by the OOM killer. This guide covers detecting, diagnosing, and fixing memory leaks in production Node.js applications on your VPS.
Detect Memory Leaks
# Monitor Node.js memory from outside
watch -n 5 "ps -o pid,rss,vsz,command -p $(pgrep -f 'node')"
# Check if memory grows over time
while true; do
rss=$(ps -o rss= -p $(pgrep -f 'node app.js'))
echo "$(date '+%H:%M:%S') RSS: ${rss}KB"
sleep 60
done | tee /tmp/node-memory.log
# Inside your app, track memory
setInterval(() => {
const mem = process.memoryUsage();
console.log(JSON.stringify({
timestamp: new Date().toISOString(),
rss: Math.round(mem.rss / 1024 / 1024),
heapUsed: Math.round(mem.heapUsed / 1024 / 1024),
heapTotal: Math.round(mem.heapTotal / 1024 / 1024),
external: Math.round(mem.external / 1024 / 1024),
}));
}, 30000); // Every 30 seconds
Common Causes
- Growing arrays/maps: Caches without eviction, event listeners accumulating
- Closures: Variables captured in closures that prevent garbage collection
- Event listeners: Adding listeners without removing them (EventEmitter leak warning)
- Global variables: Data stored globally that grows over time
- Uncleared timers: setInterval/setTimeout references preventing GC
- Stream backpressure: Readable streams producing faster than writable consumes
Take Heap Snapshots
// Add to your app for on-demand snapshots
const v8 = require('v8');
const fs = require('fs');
function takeHeapSnapshot() {
const filename = `/tmp/heap-${Date.now()}.heapsnapshot`;
const snapshotStream = v8.writeHeapSnapshot(filename);
console.log(`Heap snapshot written to ${filename}`);
return filename;
}
// Trigger via signal
process.on('SIGUSR2', () => {
takeHeapSnapshot();
});
// Or via HTTP endpoint (development only!)
app.get('/debug/heap', (req, res) => {
const file = takeHeapSnapshot();
res.json({ snapshot: file });
});
// Take snapshots at intervals to compare
// kill -USR2
// Download the .heapsnapshot file
// Open in Chrome DevTools > Memory tab
Analyze with Chrome DevTools
# Start Node.js with inspector
node --inspect app.js
# Or attach to running process
kill -USR1 $(pgrep -f 'node app.js')
# This enables the inspector on port 9229
# SSH tunnel for remote debugging
ssh -L 9229:localhost:9229 user@your-vps
# Open chrome://inspect in Chrome
# 1. Take Heap Snapshot #1
# 2. Wait/exercise the app
# 3. Take Heap Snapshot #2
# 4. Compare: select snapshot #2, switch to "Comparison" view
# 5. Sort by "# Delta" to find growing objects
Fix Common Leaks
// LEAK: Growing cache without limits
const cache = {};
function getUser(id) {
if (!cache[id]) cache[id] = fetchUser(id); // Never evicted!
return cache[id];
}
// FIX: Use LRU cache with size limit
const LRU = require('lru-cache');
const cache = new LRU({ max: 500, ttl: 1000 * 60 * 5 });
// LEAK: Event listeners not removed
socket.on('data', handleData);
// If socket is recreated but handlers aren't removed...
// FIX: Clean up listeners
socket.on('data', handleData);
socket.on('close', () => {
socket.removeListener('data', handleData);
});
// LEAK: Closures holding references
function processLargeData(data) {
const bigBuffer = Buffer.alloc(100 * 1024 * 1024);
return function callback() {
// bigBuffer is captured in closure even if not used!
return data.length;
};
}
// FIX: Don't capture unnecessary variables
function processLargeData(data) {
const length = data.length;
return function callback() {
return length; // Only captures the primitive
};
}
// LEAK: Uncleared intervals
const interval = setInterval(checkHealth, 5000);
// If the module is replaced/reloaded, interval keeps running
// FIX: Clear on shutdown
process.on('SIGTERM', () => {
clearInterval(interval);
process.exit(0);
});
Production Monitoring
# Use --max-old-space-size to set memory limit
node --max-old-space-size=1024 app.js # 1GB limit
# Enable GC logging
node --trace-gc app.js 2>&1 | grep "Mark-sweep"
# Watch for growing heap after GC
# Automatic heap dump on OOM
node --heapsnapshot-signal=SIGUSR2 --max-old-space-size=1024 app.js
# Use clinic.js for production profiling
npx clinic doctor -- node app.js
npx clinic heapprofiler -- node app.js
Best Practices
- Monitor heap usage with
process.memoryUsage()and alert on continuous growth - Set
--max-old-space-sizeto prevent the app from consuming all server memory - Use bounded caches: Always set size limits on in-memory caches
- Remove event listeners when they're no longer needed
- Take comparison heap snapshots to identify what's growing between snapshots
- Profile regularly: Run clinic.js or heapprofiler as part of your testing