Kebocoran memori jarang terlihat di menit pertama. Halaman toko Budi dibuka lancar, tetapi setelah setengah jam memakainya — membuka panel, menutupnya, membuka lagi — tab browser makan ratusan MB dan semuanya melambat. Pola ini hampir selalu berarti satu hal: data yang sudah tidak dibutuhkan ternyata masih terjangkau bagi garbage collector, karena ada jalur referensi yang tersisa tanpa disadari.
Tiga sumber kebocoran paling umum di kode JavaScript adalah event listener yang tidak dilepas, interval yang tidak dihentikan, dan closure yang menahan data besar. Chapter ini membedah ketiganya lengkap dengan pola aman yang teruji.
Listener yang Tidak Pernah Dilepas #
Mekanismenya: setiap addEventListener menyimpan referensi function ke dalam registry milik elemen. Elemen halaman utama hidup selama halaman hidup, dan function di registry-nya ikut terjangkau — termasuk seluruh variabel yang dirujuk function itu. Menambah listener berulang kali tanpa melepasnya berarti menumpuk entri yang tidak pernah dibersihkan.
Pola amannya: simpan referensi function, lalu lepas dengan removeEventListener yang memakai referensi yang sama persis. Kita buktikan dengan penghitung klik:
// pasang-lepas-listener.js — listener yang dilepas tidak lagi berbunyi
const tombol = document.querySelector("#simpan");
let jumlahKlik = 0;
function catatKlik() {
jumlahKlik++;
}
tombol.addEventListener("click", catatKlik);
tombol.click();
tombol.click();
console.log("dua klik :", jumlahKlik);
tombol.removeEventListener("click", catatKlik);
tombol.click();
console.log("klik lagi:", jumlahKlik);Output yang Dihasilkan (pada tab Console browser):
dua klik : 2
klik lagi: 2Mari kita telaah baris-baris pentingnya:
- Baris 5–7 (function
catatKlik): function dideklarasikan sebagai nama tersendiri, bukan function anonim di dalamaddEventListener. Penamaan ini syarat teknis pola aman —removeEventListenerbutuh referensi yang sama, dan function anonim baru berarti function berbeda meski isinya serupa. - Baris 9–12 (pasang lalu dua klik): dua pemanggilan
tombol.click()menghasilkan2— listener terpasang dan bekerja seperti biasa. - Baris 14 (
removeEventListener("click", catatKlik)): entri di registry elemen dilepas. Mulai titik ini, objecttomboltidak lagi menahancatatKlik— danjumlahKlikbersama scope-nya ikut kehilangan jalur dari elemen. - Baris 15–16 (klik setelah dilepas): klik ketiga berjalan tanpa efek pada penghitung — tetap
2. Perilaku ini bukti bahwa pelepasan listener benar-benar berhenti di tengah, bukan sekadar “dibiarkan”.
Pada aplikasi nyata, pola ini menempel pada siklus hidup komponen: panel yang ditutup melepas listener-nya sendiri sebelum node-nya dihapus. Alternatif modern yang mengurangi pekerjaan manual: opsi { once: true } untuk listener sekali pakai, dan AbortController untuk melepas sekelompok listener sekaligus — keduanya dibahas di section Browser APIs dan Network dan API.
Jebakan yang paling sering terjadi justru di sini: listener dipasang dengan function anonim sehingga pelepasannya mustahil:
tombol.addEventListener("click", () => {
jumlahKlik++; // function anonim: registry memegang identitas sendiri
});
tombol.removeEventListener("click", () => {
jumlahKlik++;
}); // tidak berpengaruh — ini function lain, bukan yang terpasang
Kedua function di atas terlihat sama, tetapi setiap penulisan literal function membuat identitas baru — dan removeEventListener mencocokkan identitas, bukan isi. Pola catatKlik bernama pada berkas utama ada justru untuk menghindari jebakan ini.
Interval yang Tidak Pernah Dihentikan #
setInterval menugaskan runtime memanggil callback berulang-ulang sampai clearInterval dipanggil dengan id timernya. Karena itulah interval yang dilupakan adalah kebocoran ganda: callback-nya terus dieksekusi memakan waktu proses, dan seluruh data yang dirujuk callback tetap terjangkau. Berkas berikut membuktikan bahwa clearInterval menghentikan detak untuk selamanya:
// timer-terkontrol.mjs — clearInterval menghentikan detak untuk selamanya
let detak = 0;
const jam = setInterval(() => {
detak++;
}, 5);
await new Promise((selesai) => setTimeout(selesai, 40));
clearInterval(jam);
const detakSaatBerhenti = detak;
await new Promise((selesai) => setTimeout(selesai, 40));
console.log("bertambah setelah clearInterval:", detak !== detakSaatBerhenti);Menjalankan node timer-terkontrol.mjs menghasilkan:
Output yang Dihasilkan:
bertambah setelah clearInterval: falseMari kita telaah baris-baris pentingnya:
- Baris 4–6 (
setInterval(..., 5)): callback menaikkandetaksetiap 5 milidetik, dan id-nya disimpan dijam. Menyimpan id inilah langkah yang paling sering dilewatkan pemula. - Baris 8–10 (menunggu lalu berhenti): setelah 40 milidetik,
clearInterval(jam)menghentikan timer dandetakSaatBerhentimencatat posisi penghitung saat itu. Jumlah detaknya sendiri berbeda-beda antar mesin — yang penting bukan angkanya, tetapi keberadaan titik berhenti. - Baris 12–13 (menunggu lagi, lalu membandingkan): 40 milidetik kedua memberi waktu bagi timer yang “belum padam” untuk menaikkan
detak— bila itu terjadi, perbandingan menghasilkantrue. Keluaranfalseadalah bukti deterministik bahwa setelahclearIntervaltidak ada satu pun detak baru, di mesin apa pun.
Gejala dua sumber pertama di atas bisa dikonfirmasi di sisi browser: tab Memory pada DevTools menyediakan heap snapshot — potret isi memori pada satu momen. Ambil satu potret, lakukan aksi yang dicurigai (buka-tutup panel, tekan tombol) beberapa kali, ambil potret kedua, lalu bandingkan: benda yang jumlahnya terus bertambah meski aksinya selesai adalah kandidat kebocoran. Alat itu melapor di sisi browser; kebiasaan menulis yang bersih tetap dibangun dari sisi kode — dan sumber ketiga berada tepat di bawah.
Closure yang Menahan Data Besar #
Sumber ketiga paling sulit dilihat: closure mempertahankan seluruh binding yang dirujuknya. Function kecil yang mengakses satu elemen array besar membuat array besar itu tetap terjangkau selama function-nya hidup. Perbandingan berikut memperlihatkan versi penahan dan versi ramping:
// tutup-rangkuman.js — closure menahan segala yang dirujuknya
function buatLaporanBesar() {
const baris = new Array(100000).fill("transaksi");
return {
jumlahBaris: baris.length, // nilainya disalin keluar
barisPertama: () => baris[0], // closure menahan seluruh array
};
}
const laporan = buatLaporanBesar();
console.log(laporan.jumlahBaris);
console.log(laporan.barisPertama());
function buatLaporanRamping() {
const baris = new Array(100000).fill("transaksi");
const ringkasan = { jumlah: baris.length, awal: baris[0] };
return {
ringkasan: () => ringkasan, // hanya ringkasan yang ditahan
};
}
const laporanRamping = buatLaporanRamping();
console.log(laporanRamping.ringkasan().jumlah);
console.log(laporanRamping.ringkasan().awal);Menjalankan node tutup-rangkuman.js menghasilkan:
Output yang Dihasilkan:
100000
transaksi
100000
transaksiMari kita telaah baris-baris pentingnya:
- Baris 5 (
jumlahBaris: baris.length):lengthadalah number — nilainya disalin keluar, tidak menahan array. Properti semacam ini aman meski laporannya dipakai selamanya. - Baris 6 (
barisPertama: () => baris[0]): arrow function merujukbaris, sehingga selama objectlaporanterjangkau, array 100.000 elemen ikut terjangkau — meski yang dipakai hanya satu elemen. Keluarantransaksimembuktikan array itu memang masih utuh di tangan closure. - Baris 16 (
const ringkasan = { ... }): versi ramping mengambil keputusan di titik lain — nilai yang benar-benar dibutuhkan disalin ke object kecilringkasan, lalu array besar dibiarkan tanpa jalur. - Baris 18 (
ringkasan: () => ringkasan): closure sekarang hanya menahan object dua field. SetelahbuatLaporanRampingselesai, tidak ada jalur menuju array 100.000 elemen — GC berhak mengangkatnya kapan pun ia berjalan.
Bedah keduanya menghasilkan satu kaidah: yang ditahan closure sebaiknya sekecil kebutuhan. Bila pemakai hanya butuh ringkasan, salin ringkasannya — jangan berikan tombol kecil yang menggenggam gudang data.
Sebagai rangkuman, empat kebiasaan aman dari chapter ini:
- Simpan referensi function listener, lepas dengan
removeEventListenersaat komponen selesai. - Simpan id setiap
setInterval/setTimeoutyang berulang, danclearIntervalsaat layarnya ditutup. - Di closure yang hidup lama, rujuk nilai hasil salinan — bukan array besar mentah.
- Anggap setiap pemakaian memori panjang harus punya titik akhir yang tertulis di kode.
Cara memeriksa kebenaran pola-pola ini di kode nyata adalah mengukurnya — dan pengukuran itulah topik chapter berikutnya.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
pasang-lepas-listener.js
Unduh berkas// pasang-lepas-listener.js — listener yang dilepas tidak lagi berbunyi
const tombol = document.querySelector("#simpan");
let jumlahKlik = 0;
function catatKlik() {
jumlahKlik++;
}
tombol.addEventListener("click", catatKlik);
tombol.click();
tombol.click();
console.log("dua klik :", jumlahKlik);
tombol.removeEventListener("click", catatKlik);
tombol.click();
console.log("klik lagi:", jumlahKlik);
tutup-rangkuman.js
Unduh berkas// tutup-rangkuman.js — closure menahan segala yang dirujuknya
function buatLaporanBesar() {
const baris = new Array(100000).fill("transaksi");
return {
jumlahBaris: baris.length, // nilainya disalin keluar
barisPertama: () => baris[0], // closure menahan seluruh array
};
}
const laporan = buatLaporanBesar();
console.log(laporan.jumlahBaris);
console.log(laporan.barisPertama());
function buatLaporanRamping() {
const baris = new Array(100000).fill("transaksi");
const ringkasan = { jumlah: baris.length, awal: baris[0] };
return {
ringkasan: () => ringkasan, // hanya ringkasan yang ditahan
};
}
const laporanRamping = buatLaporanRamping();
console.log(laporanRamping.ringkasan().jumlah);
console.log(laporanRamping.ringkasan().awal);