Pagi itu saya bangun karena notifikasi Slack: "Production API memory naik 2GB dalam 1 jam. Restart dulu biar aman." Restart memang selamatkan siang itu, tapi masalahnya nggak kelar. Besok pagi lagi naik. Lagi dan lagi. Kalau kamu pernah pengalaman begini, artikel ini buat kamu.
Kenapa Memory Leak Bikin Pusing
Node.js pakai V8 engine dengan garbage collector (GC) yang canggih. Teoritis, kamu nggak perlu manual free memory seperti C/C++. Tapi GC cuma bisa bersihin object yang tidak ada referensi lagi. Kalau kode kamu nyimpan referensi tanpa sadar closure, global variable, event listener yang nggak di-unsubscribe GC nggak bisa hapus. Memory numpuk. Proses crash.
Gejala klasik: process.memoryUsage().heapUsed naik terus meskipun traffic stabil. GC jalan tapi heap nggak turun. --max-old-space-size cuma nunda masalah, bukan solusi.
Tools yang Wajib Kamu Kuasai
1. node --inspect + Chrome DevTools
Cara paling cepat snapshot heap production tanpa install tambahan:
# Jalankan app dengan inspector
node --inspect=0.0.0.0:9229 app.js
# Atau kalau sudah jalan, attach ke proses existing
node --inspect -p $(pgrep -f "app.js")
Buka Chrome chrome://inspect klik "Open dedicated DevTools for Node". Tab Memory ambil heap snapshot. Bandingin 2 snapshot (sebelum/sesudah load test). Filter "Objects allocated between snapshots" itu bocorannya.
2. clinic.js Doctor untuk Node App
clinic doctor bukan cuma CPU profiler. Dia deteksi event loop delay, memory growth, dan handle leak sekaligus.
npm install -g clinic
clinic doctor -- node app.js
# Load test sementara clinic jalan
wrk -t4 -c100 -d30s http://localhost:3000/api/endpoint
# Buka file .clinic-doctor.html yang ter-generate
Output HTML-nya interaktif. Klik tab "Memory" grafik naik turun heap + highlight fungsi yang allocate paling banyak.
3. heapdump untuk Post-Mortem
Kalau proses udah mati (OOM killed), heapdump nulis file .heapsnapshot saat crash:
// Di awal app.js, SEBELUM require lain
const heapdump = require('heapdump');
// Tulis snapshot manual via signal
process.on('SIGUSR2', () => {
heapdump.writeSnapshot((err, filename) => {
console.log('Heap dump:', filename);
});
});
// Atau otomatis saat memory critical
setInterval(() => {
const used = process.memoryUsage().heapUsed;
if (used > 1.5 * 1024 * 1024 * 1024) { // 1.5GB
heapdump.writeSnapshot();
}
}, 60000);
File snapshot bisa dibuka di Chrome DevTools Memory tab sama seperti live snapshot.
Pola Bocor Yang Sering Ketemu
1. Event Listener Nggak Di-Unsubscribe
Classic. Kamu emitter.on('event', handler) tapi nggak pernah emitter.off('event', handler). Setiap request bikin listener baru. Lebih parah kalau pakai library event-based kayak socket.io atau amqplib.
// SALAH - listener numpuk tiap request
app.get('/stream', (req, res) => {
const handler = (data) => res.write(data);
eventEmitter.on('data', handler); // Tidak pernah di-off!
});
// BENAR - cleanup pas response selesai
app.get('/stream', (req, res) => {
const handler = (data) => res.write(data);
eventEmitter.on('data', handler);
req.on('close', () => {
eventEmitter.off('data', handler); // Cleanup wajib
});
});
2. Closure Menyimpan Referensi Besar
Arrow function di dalam loop atau request handler sering bikin closure yang "nge-lock" variable besar.
// SALAH - `bigData` terjebak di closure setiap request
const bigData = loadHugeDataset(); // 500MB
app.get('/search', (req, res) => {
const results = bigData.filter(item => item.name.includes(req.query.q));
res.json(results);
});
// BENAR - lazy load atau WeakRef
let bigDataCache = null;
app.get('/search', async (req, res) => {
if (!bigDataCache) bigDataCache = await loadHugeDataset();
const results = bigDataCache.filter(item => item.name.includes(req.query.q));
res.json(results);
});
// Atau pakai WeakRef kalau boleh di-GC
const dataRef = new WeakRef(await loadHugeDataset());
app.get('/search', (req, res) => {
const bigData = dataRef.deref();
if (!bigData) return res.status(503).send('Data loading...');
// ...
});
3. Global Cache Tanpa Eviction Policy
global.cache = {} atau const cache = new Map() yang nggak pernah dibersihkan. Semakin lama jalan, semakin penuh.
// SALAH - cache grow forever
const userCache = new Map();
app.get('/user/:id', async (req, res) => {
if (!userCache.has(req.params.id)) {
userCache.set(req.params.id, await fetchUser(req.params.id));
}
res.json(userCache.get(req.params.id));
});
// BENAR - LRU cache dengan size limit
const { LRUCache } = require('lru-cache');
const userCache = new LRUCache({
max: 1000, // max 1000 entries
maxSize: 50 * 1024 * 1024, // 50MB max
sizeCalculation: (value) => JSON.stringify(value).length,
ttl: 1000 * 60 * 10 // 10 menit TTL
});
4. Buffer/Stream Yang Nggak Di-Release
File upload, image processing, PDF generation kalau pakai Buffer atau stream tanpa .destroy() atau .end(), memory nggak balik ke pool.
// SALAH - buffer accumulate
app.post('/upload', (req, res) => {
const chunks = [];
req.on('data', chunk => chunks.push(chunk));
req.on('end', () => {
const buffer = Buffer.concat(chunks); // Tetap di memory sampai GC
processImage(buffer);
res.send('ok');
});
});
// BENAR - stream pipeline, auto cleanup
const { pipeline } = require('stream/promises');
const sharp = require('sharp');
app.post('/upload', async (req, res) => {
try {
await pipeline(
req,
sharp().resize(800).jpeg({ quality: 80 }),
fs.createWriteStream(`/uploads/${Date.now()}.jpg`)
);
res.send('ok');
} catch (err) {
res.status(500).send(err.message);
}
});
Workflow Debugging Saya
- Reproduce di local pakai
wrkatauk6load test 5-10 menit sambil monitorprocess.memoryUsage()setiap 30 detik. - Ambil 3 heap snapshot awal, menit ke-5, menit ke-10. Bandingin snapshot 1 vs 3.
- Filter "retained size" descending object paling besar di retention path biasanya si pelaku.
- Trace ke code DevTools tunjukin retention path:
Global Module Closure Array Object. Cari variable name yang familiar. - Fix & verify apply fix, ulang load test, pastiin heap stabil.
Snippet Monitoring Otomatis
Taruh di file terpisah, require di paling atas app.js:
// memory-guard.js
const HEAP_LIMIT_MB = 1500; // Adjust sesuai --max-old-space-size
const CHECK_INTERVAL_MS = 30000;
let lastHeapUsed = 0;
let growthCount = 0;
setInterval(() => {
const mem = process.memoryUsage();
const heapUsedMB = Math.round(mem.heapUsed / 1024 / 1024);
const rssMB = Math.round(mem.rss / 1024 / 1024);
console.log(`[MEM] heap: ${heapUsedMB}MB | rss: ${rssMB}MB | external: ${Math.round(mem.external/1024/1024)}MB`);
// Deteksi growth terus-menerus
if (heapUsedMB > lastHeapUsed) {
growthCount++;
if (growthCount > 5) { // Naik 5x berurutan
console.error('[MEM] WARNING: Heap growing continuously!');
// Trigger heapdump atau alert ke monitoring
}
} else {
growthCount = 0;
}
lastHeapUsed = heapUsedMB;
// Hard limit restart graceful
if (heapUsedMB > HEAP_LIMIT_MB) {
console.error('[MEM] CRITICAL: Heap limit exceeded, initiating graceful shutdown');
server.close(() => {
process.exit(1); // Let PM2/K8s restart
});
}
}, CHECK_INTERVAL_MS);
Production Checklist Sebelum Deploy
- Load test minimal 30 menit di staging dengan traffic mirip production. Monitor heap.
- Set
--max-old-space-sizesesuai RAM container (misal 2GB RAM--max-old-space-size=1536). - Enable
NODE_OPTIONS=--heap-profuntuk profil heap sampling berkelanjutan. - Alerting Grafana/Prometheus alert kalau
process_memory_bytesnaik > 10% per jam. - Graceful shutdown handle
SIGTERM, close DB connection, stop accept request, baru exit.
Kesimpulan
Memory leak di Node.js bukan bug V8 bug kode kita yang nge-retain referensi tanpa sadar. Tools seperti --inspect, clinic.js, heapdump bantu ketemu bocorannya. Tapi paling penting: biasakan cleanup. off() listener, destroy() stream, clearInterval(), pakai WeakMap/WeakRef kalau cache boleh dihapus GC.
Kalau kamu punya kasus memory leak yang aneh (misal cuma bocor pas pake library tertentu), share di komentar. Saya bantu trace retention path-nya. Atau kalau punya tool favorit lain selain clinic, kasih tau saya juga belajar dari komunitas.
Referensi cepat: