Node.js memory management is a bitch. The V8 heap limit defaults to ~1.4GB on 64-bit systems. Hit it and your app dies with FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
.
I've debugged dozens of memory leaks in production. Here's what actually causes them and how to fix them before they kill your containers.
Real Memory Leak #1: Unclosed Database Connections
// This killed a payment processing app
app.get('/users/:id', async (req, res) => {
const db = await mysql.createConnection(config);
const user = await db.query('SELECT * FROM users WHERE id = ?', [req.params.id]);
res.json(user);
// db.end() is missing - connection stays open forever
});
Symptoms: Memory usage climbs steadily. Container gets OOMKilled every 2-4 hours. Error logs show `too many connections` from the database. Monitor with container memory limits to catch this early.
Fix: Always close connections or use connection pooling:
const pool = mysql.createPool(config);
app.get('/users/:id', async (req, res) => {
const connection = await pool.getConnection();
try {
const user = await connection.query('SELECT * FROM users WHERE id = ?', [req.params.id]);
res.json(user);
} finally {
connection.release(); // This is critical
}
});
Real Memory Leak #2: Event Listener Accumulation
// This one crashed a real-time chat app
const EventEmitter = require('events');
const emitter = new EventEmitter();
app.post('/join-room', (req, res) => {
emitter.on('message', (data) => {
// Handler for this user
res.write(`data: ${data}
`);
});
// No emitter.off() - listeners accumulate forever
});
Symptoms: Memory grows with each user action. HeapSnapshot shows thousands of identical listeners. App gets slower over time. Use Chrome DevTools for Node.js debugging to inspect listener accumulation.
Fix: Always clean up listeners:
app.post('/join-room', (req, res) => {
const handler = (data) => {
res.write(`data: ${data}
`);
};
emitter.on('message', handler);
req.on('close', () => {
emitter.off('message', handler); // Clean up on disconnect
});
});
Memory Leak #3: Global Array Growth
This one's subtle and deadly:
// Logging system that became a memory monster
const requestLogs = []; // Global array
app.use((req, res, next) => {
requestLogs.push({
url: req.url,
timestamp: new Date(),
userAgent: req.headers['user-agent']
});
next();
});
What happens: Array grows forever. Each request adds data but nothing removes it. After 100k requests, you're out of memory. This is a classic memory leak pattern that kills production apps.
Fix: Implement rotation or use a proper logging library like Winston or Pino:
const winston = require('winston');
const logger = winston.createLogger({
transports: [
new winston.transports.File({ filename: 'requests.log', maxsize: 10485760 }) // 10MB max
]
});
app.use((req, res, next) => {
logger.info({ url: req.url, timestamp: new Date() });
next();
});
Debugging Memory Leaks That Don't Crash
The worst memory leaks don't crash your app - they just make it slower and slower until users complain.
Tools that actually work:
clinic.js - Best overall profiler with multiple diagnostic tools
clinic doctor -- node app.js clinic flame -- node app.js
0x flame graphs - Visualize the call stack with interactive flame graphs
0x node app.js
Built-in profiling (Node.js 24+ has improved profiling significantly):
node --prof app.js node --prof-process isolate-*.log > processed.txt
HeapSnapshot comparison (this saved my ass multiple times):
const v8 = require('v8'); const fs = require('fs'); // Take snapshot before suspected leak const snapshot1 = v8.writeHeapSnapshot('./heap-before.heapsnapshot'); // ... run your suspected code // Take snapshot after const snapshot2 = v8.writeHeapSnapshot('./heap-after.heapsnapshot');
When Memory Leaks Hide in Dependencies
The nastiest memory leaks hide in third-party packages. I spent 8 hours debugging a leak in a socket.io app before finding it was a bug in the Redis adapter version we were using. Dependency memory leaks are the hardest to diagnose.
Pro tip: Use `npm ls` to audit your dependency tree. Look for packages that haven't been updated recently - they're leak suspects. Monitor dependency updates regularly.
npm ls | grep -E "(deduped|MISSING|extraneous)"
Memory monitoring that works in production:
const used = process.memoryUsage();
for (let key in used) {
console.log(`${key}: ${Math.round(used[key] / 1024 / 1024 * 100) / 100} MB`);
}
Add this to a `/health` endpoint and monitor it. When heapUsed
keeps climbing without dropping, you've got a leak. Production monitoring tools like New Relic or DataDog can alert on memory growth patterns.
Memory leaks are inevitable in complex Node.js apps. The key is catching them early and having the right debugging arsenal ready to debug when they happen at 3 AM. Event loop monitoring should be part of your standard production setup.