Memory leaks in Node.js are sneaky. Your app works fine in dev, passes all tests, then slowly eats RAM in production until it crashes at 3 AM.
Here's how to find and fix them — no PhD required.
The Symptoms
Before debugging, confirm you actually have a leak:
# Watch memory usage over time
node --expose-gc -e 'setInterval(() => { global.gc(); console.log(process.memoryUsage().heapUsed / 1024 / 1024, "MB"); }, 1000);'
If heapUsed keeps growing after forced GC, you have a leak.
The Usual Suspects
1. Event Listeners That Never Die
The #1 cause. Every .on() without a corresponding .off() is a potential leak.
// ❌ Leak: listener accumulates on every request
app.get('/stream', (req, res) => {
database.on('change', (data) => {
res.write(JSON.stringify(data));
});
});
// ✅ Fixed: clean up when connection closes
app.get('/stream', (req, res) => {
const handler = (data) => res.write(JSON.stringify(data));
database.on('change', handler);
req.on('close', () => database.off('change', handler));
});
Node warns you at 11 listeners per emitter. Don't suppress this warning — fix the leak.
2. Closures Holding References
// ❌ Leak: closure keeps `hugeData` alive
function processRequest(hugeData) {
return function respond() {
// Doesn't use hugeData, but closure captures it anyway
return { status: 'ok' };
};
}
// ✅ Fixed: null out after use
function processRequest(hugeData) {
const result = transform(hugeData);
hugeData = null; // Allow GC
return function respond() {
return result;
};
}
3. Caches Without Limits
// ❌ Leak: grows forever
const cache = {};
function getUser(id) {
if (!cache[id]) cache[id] = db.findUser(id);
return cache[id];
}
// ✅ Fixed: use an LRU cache
import { LRUCache } from 'lru-cache';
const cache = new LRUCache({ max: 500 });
function getUser(id) {
if (!cache.has(id)) cache.set(id, db.findUser(id));
return cache.get(id);
}
4. Timers and Intervals
// ❌ Leak: interval keeps running after module is done
setInterval(() => {
metrics.push(getStats()); // `metrics` array grows forever
}, 1000);
// ✅ Fixed: clear interval and bound the array
const id = setInterval(() => {
metrics.push(getStats());
if (metrics.length > 1000) metrics.shift();
}, 1000);
// Clear when done
process.on('SIGTERM', () => clearInterval(id));
Finding the Leak
Step 1: Heap Snapshots
const v8 = require('v8');
const fs = require('fs');
// Take a snapshot
const snapshotStream = v8.writeHeapSnapshot();
console.log('Snapshot written to', snapshotStream);
Take two snapshots 5 minutes apart, then compare in Chrome DevTools:
- Open
chrome://inspect - Click "Open dedicated DevTools for Node"
- Go to Memory tab → Load both snapshots
- Switch to "Comparison" view
- Sort by "Size Delta" — the growing objects are your leak
Step 2: The Allocation Timeline
For harder cases, use --inspect with the allocation timeline:
node --inspect server.js
- Open Chrome DevTools → Memory tab
- Select "Allocation instrumentation on timeline"
- Hit record, exercise your app, stop recording
- Blue bars that don't disappear = retained memory = leak candidates
Step 3: Production Profiling
Can't reproduce locally? Use process.memoryUsage() strategically:
// Add to your healthcheck endpoint
app.get('/health', (req, res) => {
const mem = process.memoryUsage();
res.json({
heapUsed: Math.round(mem.heapUsed / 1024 / 1024) + ' MB',
heapTotal: Math.round(mem.heapTotal / 1024 / 1024) + ' MB',
rss: Math.round(mem.rss / 1024 / 1024) + ' MB',
external: Math.round(mem.external / 1024 / 1024) + ' MB',
uptime: Math.round(process.uptime()) + 's'
});
});
Graph this over time. If heapUsed trends up across restarts, you have a leak.
The Nuclear Option
If you can't find the leak and need a quick fix:
// Restart worker when memory exceeds threshold
if (process.memoryUsage().heapUsed > 500 * 1024 * 1024) {
console.error('Memory limit reached, restarting...');
process.exit(1); // Let your process manager restart you
}
This isn't a fix — it's a bandaid. But it keeps your app running while you investigate.
TL;DR
- Event listeners are the #1 cause — always clean up
- Closures capture more than you think
- Unbounded caches are silent killers
- Heap snapshots + comparison view = fastest diagnosis
- Don't suppress the MaxListenersExceededWarning
Memory leaks aren't mysterious. They're just references that outlive their usefulness — like that gym membership you're still paying for.
Found this useful? I write practical dev guides weekly. Follow for more no-fluff tutorials.
Top comments (0)