Introduction
In real-world Node.js backend systems, not all issues explode with clear stack traces. Some of the most dangerous problems grow silently over time.
You deploy your API. Everything looks stable. CPU is fine. Logs are clean.
Then slowly:
- Memory usage increases
- CPU starts spiking
- Response time degrades
- And suddenly… your process crashes
This is the nature of a Memory Leak in Node.js.
In this article, I’ll walk through a real production case, explain the exact root causes, and show practical, production-ready solutions.
System Context
- Node.js (Express)
- Prisma ORM
- SQLite → PostgreSQL
- PM2
- Nginx
- VPS (Ubuntu)
Traffic:
- ~500–1500 req/min
Symptoms Observed
- RAM: 300MB → 1.8GB
- CPU spikes
- Slow responses
- Process restarts
Step 1: Confirming a Memory Leak
setInterval(() => {
const used = process.memoryUsage();
console.log({
rss: used.rss,
heapTotal: used.heapTotal,
heapUsed: used.heapUsed,
external: used.external,
});
}, 5000);
Observation
- <code>heapUsed</code> continuously increased
- Garbage Collector did NOT free memory
- No stabilization plateau
Conclusion:
This is a true memory leak
Step 2: Heap Analysis
node --inspect index.js
chrome://inspect
- Open Memory tab
- Take Heap Snapshots
Findings
- Thousands of retained objects
- Growing references
- No garbage collection
Root Causes
Unbounded Cache
const cache = {};
Event Listener Leak
someEmitter.on('event', () => {});
Unresolved Promise
new Promise(() => {});
Production Fixes (Copy-Paste)
// LRU CACHE
import LRU from 'lru-cache';
const cache = new LRU({
max: 500,
ttl: 1000 60 5,
});
// EVENT CLEANUP
const handler = () => {};
someEmitter.on('event', handler);
someEmitter.off('event', handler);
// SAFE ASYNC
async function safeAsync() {
try {
return await someAsyncTask();
} catch (e) {
console.error(e);
}
}
// REDIS CACHE
import Redis from 'ioredis';
const redis = new Redis();
async function getData(key) {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const data = await getDataFromDB(key);
await redis.set(key, JSON.stringify(data), 'EX', 300);
return data;
}
// STREAM
import fs from 'fs';
app.get('/file', (req, res) => {
fs.createReadStream('./large-file.txt').pipe(res);
});
// MONITOR
setInterval(() => {
console.log(process.memoryUsage().heapUsed);
}, 10000);
Results
| Metric | Before | After |
|------|------|------|
| RAM | 1.8GB | ~350MB |
| Stability | ❌ | ✅ |
| Crash | Yes | No |
Insights
- Most leaks = bad references
- GC depends on references
- Global state = risk
Best Practices
- Use LRU / Redis
- Avoid global memory growth
- Clean listeners
- Resolve promises
- Monitor memory
Monitoring
pm2 monit
pm2 install pm2-server-monit
Scaling
- Redis
- Queue (BullMQ)
- Microservices
- Observability
Final Thoughts
Memory leaks in Node.js are:
- Silent
- Dangerous
- Preventable
