Di JavaScript kita tidak mengalokasikan maupun membebaskan memori secara manual — pekerjaan itu dipegang garbage collector (GC) milik V8. Kebebasan ini bukan berarti bebas tanggung jawab: kode yang menahan referensi lebih lama dari perlunya akan membuat heap membengkak, GC bekerja makin sering, dan latency permintaan ikut naik. Chapter ini membahas cara V8 mengatur heap, tiga pola kebocoran memori yang paling sering dijumpai beserta perbaikannya, dan referensi lemah sebagai juru damai antara cache dan GC.
Heap V8 yang Terbagi Generasi #
V8 mengelola object di heap dengan asumsi yang disebut generational hypothesis: kebanyakan object hidupnya singkat. Berdasarkan asumsi itu, heap dibagi menjadi dua ruang utama:
| Ruang | Penghuni | Karakteristik GC |
|---|---|---|
| New Space (young generation) | Object baru berumur pendek: hasil parse, object per request, string sementara | Dikumpulkan sering, cepat, dan murah |
| Old Space (old generation) | Object yang selamat dari beberapa putaran GC lalu dipromosikan: konfigurasi, koneksi pool, data cache | Dikumpulkan jarang tetapi mahal dan bisa menyebabkan jeda lebih terasa |
Ketika sebuah request datang, object sementaranya lahir di New Space dan mati di sana juga — GC minor membersihkannya dengan cepat. Object yang terus bertahan — misalnya Map sesi yang tumbuh tanpa pembersihan — dipromosikan ke Old Space, dan di sanalah masalah mulai mahal: mayoritas kebocoran memori di Node.js berarti “object yang seharusnya mati muda jadi penghuni tetap Old Space”. Panduan resmi Understanding and Tuning Memory
menjelaskan ruang-ruang ini lengkap dengan flag penyetelnya seperti --max-old-space-size.
Konsekuensi praktisnya untuk performa: semakin banyak sampah kita hasilkan per detik, semakin sering GC berjalan, dan sebagian dari pekerjaan itu menyita waktu CPU yang seharusnya melayani request. Aplikasi yang sehat menghasilkan object sebanyak yang diperlukan, membuangnya segera, dan tidak menyimpan apa pun lebih lama dari umur manfaatnya.
Listener yang Lupa Dilepas #
Pola kebocoran pertama melibatkan EventEmitter yang sudah kita kenal di Section Core Modules. Setiap koneksi yang memasang listener lewat on() menyimpan referensi ke handler-nya di dalam emitter — selama listener terpasang, handler beserta semua yang ditangkapnya tidak bisa dikumpulkan GC. Node.js sendiri membentengi kita: bila satu event menampung lebih dari 10 listener, proses mengeluarkan peringatan MaxListenersExceededWarning. Simulasi berikut memperlihatkan kebocoran lalu perbaikannya:
// Simulasi server yang ribuan klien hubungkan.
// Setiap koneksi memasang listener "data"; koneksi
// yang terputus wajib melepas listenernya kembali.
import { EventEmitter } from "node:events";
// Tangkap peringatan proses tanpa mencetak teks aslinya,
// karena teks asli memuat PID yang berubah tiap eksekusi.
process.on("warning", (peringatan) => {
console.log("peringatan dari proses:", peringatan.name);
});
const bus = new EventEmitter();
const listenerTiapKoneksi = [];
// Fase 1: 12 klien terhubung, masing-masing memasang satu listener.
for (let klien = 1; klien <= 12; klien++) {
const handler = () => {};
bus.on("data", handler);
listenerTiapKoneksi.push(handler);
}
console.log("listener setelah 12 klien terhubung:", bus.listenerCount("data"));
// Fase 2: 10 klien terputus dan melepas listenernya sendiri.
for (let terputus = 0; terputus < 10; terputus++) {
bus.removeListener("data", listenerTiapKoneksi.pop());
}
console.log("listener setelah 10 klien terputus:", bus.listenerCount("data"));Jalankan dengan flag --no-warnings agar teks peringatan bawaan — yang mencetak nomor PID ke stderr — tidak menodai laporan; peringatannya tetap bisa kita tangkap lewat event warning pada process:
node --no-warnings listener-sesi.mjsOutput yang Dihasilkan:
listener setelah 12 klien terhubung: 12
listener setelah 10 klien terputus: 2
peringatan dari proses: MaxListenersExceededWarningBedah baris-per-barisnya:
- Baris 8-10 (
process.on("warning", ...)): eventwarningpadaprocessmenerima semua peringatan level proses; kita hanya mencetakperingatan.nameagar output bebas dari PID dan nomor baris yang berubah antar eksekusi. - Baris 12-13 (
busdanlistenerTiapKoneksi): satu emitter dipakai bersama seluruh klien — array pendamping menyimpan handler milik tiap klien agar bisa dilepas lagi. - Baris 15-21 (fase koneksi): setiap klien memasang handler kosong. Handler yang sebenarnya biasanya menangkap buffer dan object koneksi, jadi kebocoran nyata jauh lebih gemuk daripada simulasi ini. Listener kesebelas (baris 18) melampaui batas default
EventEmitter.defaultMaxListenerssebesar 10, sehinggaMaxListenersExceededWarningterpicu — peringatan itu gejala, bukan penyakit; naikkan batas hanya bila jumlah listenernya memang wajar. - Baris 23-27 (fase pemutusan):
removeListenermelepas handler yang sama persis dengan yang dipasang, sehingga hitungan turun dari 12 ke 2 dan handler para klien yang telah pergi bisa dikumpulkan GC.
Untuk kejadian sekali pakai, method once() melepas listener-nya sendiri setelah terpicu — pilihan yang lebih tepat daripada memasang on() lalu menghapus manual di dalam handler.
Struktur Data yang Tumbuh Tanpa Batas #
Pola kedua lebih sunyi: sebuah Map yang hanya pernah diisi. Sesi pengguna adalah contoh klasiknya — login menambah entri, tetapi logout yang lupa memanggil delete() membuat entri menumpuk selamanya. Bandingkan dua implementasi berikut pada urutan kejadian yang sama:
// Dua penyimpanan sesi dengan nasib berbeda:
// satu melupakan logout, satu lagi membersihkan diri.
function buatSesiBocor() {
const simpanan = new Map();
return {
login(id) {
simpanan.set(id, { loginPada: "2026-01-15T08:00:00.000Z" });
},
logout(id) {
// kosong: pembersihan terlupakan
},
ukuran: () => simpanan.size,
};
}
function buatSesiSehat() {
const simpanan = new Map();
return {
login(id) {
simpanan.set(id, { loginPada: "2026-01-15T08:00:00.000Z" });
},
logout(id) {
simpanan.delete(id);
},
ukuran: () => simpanan.size,
};
}
const kejadian = [
["login", "budi"],
["login", "siti"],
["login", "dewi"],
["logout", "budi"],
["logout", "siti"],
["logout", "tidak-terdaftar"],
];
const bocor = buatSesiBocor();
const sehat = buatSesiSehat();
for (const [aksi, id] of kejadian) {
bocor[aksi](id);
sehat[aksi](id);
}
console.log("sesi tersisa (versi bocor):", bocor.ukuran());
console.log("sesi tersisa (versi sehat):", sehat.ukuran());Output yang Dihasilkan:
sesi tersisa (versi bocor): 3
sesi tersisa (versi sehat): 1Setelah tiga login dan tiga logout, versi bocor masih menyimpan 3 sesi sedangkan versi sehat hanya menyimpan 1 — entri budi dan siti terbersihkan saat logout, entri dewi tetap ada karena dewi memang belum logout, dan logout untuk tidak-terdaftar tidak berdampak karena delete() pada kunci yang absen tidak melempar error. Kedua versi memakai stempel waktu beku 2026-01-15T08:00:00.000Z agar laporan identik di setiap eksekusi. Membersihkan saat kejadian adalah satu jalan; jalan lain adalah memberi entri masa berlaku (TTL) — pendekatan yang akan kita bangun utuh di chapter Caching.
WeakMap dan Referensi Lemah #
Kadang kita ingin menyimpan data tambahan untuk sebuah object tanpa membuat object itu hidup lebih lama dari perlunya. WeakMap menjanjikan hal itu: kuncinya harus object, dan referensinya bersifat lemah — GC boleh mengambil object itu kapan pun tidak ada lagi referensi kuat yang menahannya:
// Metadata berkas disimpan di WeakMap: kunci berupa object,
// dan entri tidak menahan object tersebut tetap hidup.
const metadataBerkas = new WeakMap();
function catat(berkas, jumlahBaris) {
metadataBerkas.set(berkas, { jumlahBaris });
}
function baca(berkas) {
return metadataBerkas.get(berkas)?.jumlahBaris ?? null;
}
const laporanQ1 = { nama: "laporan-q1.csv" };
const laporanQ2 = { nama: "laporan-q2.csv" };
catat(laporanQ1, 1200);
catat(laporanQ2, 340);
console.log("laporan-q1.csv:", baca(laporanQ1), "baris");
console.log("laporan-q2.csv:", baca(laporanQ2), "baris");
// WeakMap tidak punya size dan tidak bisa diiterasi;
// satu-satunya jalan membacanya adalah object kuncinya.
console.log("properti size:", metadataBerkas.size);
metadataBerkas.delete(laporanQ2);
console.log("setelah delete, laporan-q2.csv:", baca(laporanQ2));
const berkasLain = { nama: "lain.csv" };
console.log("berkas tanpa metadata:", baca(berkasLain));
console.log("laporan-q1 masih tercatat?:", metadataBerkas.has(laporanQ1));Output yang Dihasilkan:
laporan-q1.csv: 1200 baris
laporan-q2.csv: 340 baris
properti size: undefined
setelah delete, laporan-q2.csv: null
berkas tanpa metadata: null
laporan-q1 masih tercatat?: truePoin penting dari output tersebut: metadataBerkas.size bernilai undefined karena WeakMap memang tidak bisa dihitung maupun diiterasi — kalau bisa, isinya akan menahan semua kuncinya tetap hidup, dan jaminan referensi lemah pun tidak berlaku. Membaca metadata hanya mungkin melalui object kuncinya sendiri, dan delete() bekerja seperti pada Map biasa. Keterangan lengkap ada di dokumentasi WeakMap di MDN
.
Saudara jauhnya, WeakRef, memegang object secara lemah dan menawarkan deref() untuk memeriksa apakah object masih hidup — dipakai untuk cache yang rela kehilangan isinya saat GC berjalan. Aturan mainnya ketat dan bergantung pada waktu GC, sehingga penggunaan WeakRef sebaiknya disertai FinalizationRegistry dan pemahaman mendalam; untuk kebutuhan cache yang bisa diatur umurnya, TTL biasa lebih mudah dinalar.
Memantau Memori dari Luar #
Untuk melihat konsumsi memori proses, process.memoryUsage() adalah titik mulai paling cepat:
console.log(process.memoryUsage());Propertinya mencakup rss (memori nyata dari sistem operasi), heapUsed dan heapTotal (pakai dan alokasi heap V8), serta external (memori di luar heap, misalnya buffer). Angka-angkanya berubah pada setiap eksekusi, jadi kekuatannya bukan pada satu nilai melainkan trennya: rss yang merambat naik selama berjam-jam di bawah beban tetap adalah sinyal kebocoran klasik. Pasangkan tren ini dengan dua heap snapshot dari chapter sebelumnya untuk menemukan object mana yang bertahan tanpa alasan.
Kebocoran yang sudah dipahami bisa dicegah dengan desain: listener dilepas saat putus, entri dihapus saat kejadian berakhir, referensi lemah untuk data opsional. Namun ada kelas masalah yang tidak bisa diatasi pembersihan memori mana pun: pekerjaan yang sama dihitung berulang kali untuk hasil yang tidak berubah. Untuk itu kita butuh cache — topik chapter berikutnya.