Event Listener & Closure Leaks — Debugging

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…

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:

  1. Performance → Memory graph — apakah trend naik terus tanpa turun?
  2. Memory → Heap snapshot — ambil snapshot, interaksi dengan app, ambil lagi, compare → "objects retained"
  3. Memory → Allocation instrumentation on timeline — lihat kapan alokasi terjadi
  4. 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:

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();