Salah satu memory leak paling umum di SPA: event listener yang tidak di-remove, plus closure yang masih pegang reference ke data besar.
Kasus 1: Listener di window/document yang tidak di-cleanup
// BUG — listener tidak di-remove saat component unmount
function Component() {
useEffect(() => {
window.addEventListener("scroll", () => {
console.log(window.scrollY);
});
// ❌ Tidak ada cleanup
}, []);
}
Jika Component mount/unmount 100 kali (misal halaman di-navigate), ada 100 listener aktif di window. Setiap listener menahan closure (scope function + variabel luar).
Fix — useEffect cleanup:
function Component() {
useEffect(() => {
const handler = () => console.log(window.scrollY);
window.addEventListener("scroll", handler);
return () => window.removeEventListener("scroll", handler);
}, []);
}
Penting: reference ke handler harus SAMA saat add dan remove. Inline arrow function berbeda tiap render → tidak bisa di-remove!
Kasus 2: Closure yang menahan data besar
function setupHandler() {
const bigData = new Array(1000000).fill("x"); // 8MB
document.getElementById("btn").addEventListener("click", () => {
console.log("clicked", bigData.length); // closure pegang bigData
});
}
setupHandler();
// bigData tidak bisa di-GC selama handler aktif
Walaupun setupHandler sudah selesai, closure handler masih reference bigData.
Fix — lepaskan reference yang tidak perlu:
function setupHandler() {
const bigData = new Array(1000000).fill("x");
const length = bigData.length; // hanya ambil yang diperlukan
document.getElementById("btn").addEventListener("click", () => {
console.log("clicked", length); // closure hanya pegang "length"
});
// bigData bisa di-GC setelah fungsi selesai
}
Kasus 3: Detached DOM node
const cache = [];
function addCard() {
const card = document.createElement("div");
cache.push(card); // cache pegang reference
document.body.appendChild(card);
}
function removeAll() {
document.body.innerHTML = "";
// ❌ cache masih pegang node — node masih di memory tapi tidak di DOM
}
Solusi: bersihkan cache juga.
Cara deteksi di Chrome DevTools:
- Performance → Memory graph — apakah trend naik terus tanpa turun?
- Memory → Heap snapshot — ambil snapshot, interaksi dengan app, ambil lagi, compare → "objects retained"
- Memory → Allocation instrumentation on timeline — lihat kapan alokasi terjadi
- DOM count — Performance monitor → "DOM Nodes". Jika DOM nodes terus naik padahal interaksi di-reset → leak
Finding listener yang belum di-remove:
// Di Chrome DevTools console
getEventListeners(window);
// { scroll: [{ listener: ƒ, useCapture: false }, ...] }
getEventListeners(document.getElementById("btn"));
Pattern aman untuk React:
function useWindowEvent(eventName, handler) {
useEffect(() => {
window.addEventListener(eventName, handler);
return () => window.removeEventListener(eventName, handler);
}, [eventName, handler]);
}
Anti-pattern:
setIntervaltanpaclearIntervaldi cleanupEventEmitter.on(...)tanpa.off(...)IntersectionObserver.observe(...)tanpa.disconnect()WebSocketyang tidak.close()fetchtanpaAbortControllersaat component unmount
AbortController (modern untuk cleanup):
const controller = new AbortController();
element.addEventListener("click", handler, { signal: controller.signal });
// Cleanup semua listener yang pakai signal ini sekaligus
controller.abort();