JavaScript membersihkan memori sendiri: garbage collector (GC) membuang data yang sudah gak bisa dijangkau lagi oleh kodemu. Masalahnya muncul kalau kode masih memegang sesuatu yang sebenarnya sudah gak dipakai. Data itu gak bisa dibuang, dan memorinya terus menumpuk. Ini disebut memory leak. Bayangin kamar kos yang tiap minggu dibersihkan petugas. Petugasnya cuma membuang barang yang gak ada pemiliknya. Kalau kamu menempel label "punyaku" di kardus bekas lalu lupa, kardusnya gak pernah dibuang, dan kamarnya makin sempit.
🧪 Tebakan cepat: sebuah modal memegang data 2 MB. Kamu sudah melepas listener-nya saat modal ditutup, tapi lupa
clearInterval. Modalnya dibuka-tutup 5 kali. Heap yang awalnya 8 MB jadi berapa: tetap 8 MB, 10,5 MB, atau 20,5 MB? Jawabannya ada di bagian "Empat penyebab yang paling sering".
Kapan memori dibersihkan
GC mulai dari akar: variabel global, window, DOM yang tampil di halaman, serta listener dan timer yang masih aktif. Semua yang bisa dijangkau dari akar itu dianggap masih dipakai. Yang gak bisa dijangkau dibuang. Kamu gak perlu (dan gak bisa) memanggil GC sendiri.
Jadi memory leak adalah memori yang sudah gak dipakai tapi gak bisa dibebaskan GC, karena masih ada referensi ke sana. Gejalanya: halaman yang awalnya lancar makin lambat setelah lama dipakai, dan tab-nya bisa crash. Di DevTools, grafik heap yang sehat naik-turun. Yang bocor naik terus seperti tangga.
Empat penyebab yang paling sering
Ini modal di Lab halaman ini. Tiap kali dibuka, dia membuat elemen modal (sekitar 0,5 MB) dan array produk (sekitar 2 MB).
const infoModal = new WeakMap(); // Map biasa bikin modal gak pernah dibuang
function bukaModal() {
const modal = document.createElement("div");
const produk = new Array(250000).fill("produk"); // sekitar 2 MB
const controller = new AbortController();
window.addEventListener("resize", () => posisikan(modal, produk), {
signal: controller.signal,
});
const id = setInterval(() => segarkan(modal, produk), 1000);
infoModal.set(modal, { dibukaPada: Date.now() });
document.body.append(modal);
return function tutupModal() {
modal.remove();
controller.abort(); // lepas listener-nya
clearInterval(id); // hentikan interval-nya
};
}
Setiap baris di tutupModal menutup satu jalur bocor:
- Listener yang gak dilepas.
windowhidup selama halaman dibuka. Dia memegang listener, listener-nya closure yang memegangmodaldanproduk. Tanpacontroller.abort(), 2,5 MB itu tertahan. - Timer yang gak dihentikan.
setIntervaljalan terus tiap detik sampai di-clearInterval. Selama itu callback dan closure-nya tetap hidup, besertamodaldanproduk. - Detached DOM.
modal.remove()mengeluarkan elemen dari halaman, tapi kalau masih ada referensi dari JavaScript (misalnya jadi key diMapbiasa), elemennya tetap di memori. Elemen seperti ini disebut detached DOM node. - Closure yang memegang data besar. Ini yang bikin dua penyebab pertama mahal: closure-nya cuma butuh
produkuntuk segarkan, tapi selama closure-nya hidup, 2 MB itu ikut hidup.
Satu jalur saja sudah cukup untuk menahan semuanya. Di tebakan tadi, listener-nya sudah dilepas, tapi interval-nya masih memegang closure, jadi tiap buka-tutup tertahan 2,5 MB. Lima kali jadi 12,5 MB, dan heap naik dari 8 MB ke 20,5 MB. Itu jawaban tebakannya.
Bersihkan saat ditutup
Patokannya: apa pun yang dipasang saat membuka, dilepas saat menutup.
setIntervaldisimpan id-nya, laluclearInterval(id). Tanpa menyimpan id-nya, interval itu gak bisa dihentikan sama sekali.- Listener dilepas dengan
removeEventListener("resize", handler), memakai function yang sama persis dengan waktu dipasang. - Untuk banyak listener sekaligus, pakai AbortController: kirim
controller.signalke tiapaddEventListener, lalu satucontroller.abort()melepas semuanya. Gak perlu menyimpan referensi tiap handler. Signal yang sama juga bisa membatalkanfetch.
WeakMap untuk data tentang elemen
Map biasa memegang key-nya dengan kuat: selama entry-nya ada, elemen yang jadi key gak bisa dibuang, walaupun sudah lepas dari halaman. WeakMap memegang key-nya dengan lemah: kalau gak ada referensi lain ke elemen itu, entry-nya ikut hilang saat GC jalan. Pakai WeakMap (atau WeakSet) untuk menyimpan data tambahan tentang elemen DOM atau object lain yang seharusnya ikut dibuang. Gantinya, WeakMap gak bisa di-loop dan gak punya .size.
WeakRef mirip, untuk satu object: ref.deref() mengembalikan object-nya, atau undefined kalau sudah dibuang GC. Jarang dibutuhkan di kode sehari-hari.
Menemukan leak di DevTools
- Buka DevTools, tab Memory, pilih Heap snapshot, lalu klik Take snapshot.
- Lakukan aksi yang dicurigai, misalnya buka-tutup modal 5 kali.
- Klik ikon tong sampah (Collect garbage) supaya yang bisa dibuang benar-benar dibuang, lalu ambil snapshot kedua.
- Di snapshot kedua, pilih tampilan Comparison. Object yang bertambah tapi seharusnya sudah hilang adalah tersangka. Ketik
Detacheddi kotak filter untuk mencari detached DOM node, lalu buka panel Retainers untuk melihat siapa yang masih memegangnya.
Mengecek pola leak dari teks kode
Latihan pelajaran ini memeriksa potongan kode sebagai teks, dengan aturan sederhana:
function findLeaks(codeSnippets) {
const leaks = [];
codeSnippets.forEach((snippet, index) => {
if (snippet.includes("setInterval") && !snippet.includes("clearInterval")) {
leaks.push({ snippet: index, leak: "interval-not-cleared" });
}
if (snippet.includes("addEventListener") && !snippet.includes("removeEventListener")) {
leaks.push({ snippet: index, leak: "listener-not-removed" });
}
});
return leaks;
}
| Panggilan | Hasil |
|---|---|
findLeaks(["setInterval(() => update(), 1000)"]) |
[{ snippet: 0, leak: "interval-not-cleared" }] |
findLeaks(["const t = setInterval(() => {}, 1000); clearInterval(t)"]) |
[] |
findLeaks(['document.addEventListener("click", fn)']) |
[{ snippet: 0, leak: "listener-not-removed" }] |
forEach((snippet, index) => ...)memberi teks potongan dan nomor urutnya, mulai dari 0.includesmengecek apakah sebuah kata ada di teks itu.- Tiap temuan berbentuk
{ snippet, leak }, dengansnippetditulis duluan.
⚠️ Jebakan yang sering ketemu
setIntervaltanpa menyimpan id-nya.setInterval(() => checkStatus(), 1000)gak bisa dihentikan, jadi callback dan closure-nya hidup selamanya.removeEventListenerdengan function baru.removeEventListener("click", () => buka())gak melepas apa-apa, karena arrow function itu bukan function yang dipasang tadi. Simpan handler-nya di variabel, atau pakai AbortController.- Menyimpan elemen di
Mapatau array global. Elemennya jadi detached DOM node setelah di-remove(). PakaiWeakMap, atau hapus entry-nya saat menutup. - Mengira
findLeaksitu pengecek sungguhan. Dia cuma membaca teks. Listener yang dilepas lewatcontroller.abort()tetap dianggap bocor, karena kataremoveEventListenergak ada.
TL;DR: Memory leak adalah memori yang sudah gak dipakai tapi gak bisa dibebaskan GC, karena masih ada referensi ke sana. Penyebab paling sering: listener yang gak dilepas, setInterval tanpa clearInterval, detached DOM node yang masih disimpan di JavaScript, dan closure yang memegang data besar. Satu jalur saja cukup: modal 2,5 MB yang dibuka-tutup 5 kali dengan interval yang lupa dihentikan bikin heap naik dari 8 MB ke 20,5 MB. Lepas semuanya saat menutup: clearInterval(id), removeEventListener dengan function yang sama, atau satu controller.abort() untuk banyak listener. Simpan data tentang elemen di WeakMap. Cari leak dengan dua heap snapshot di tab Memory dan tampilan Comparison.