JavaScript Bellek Yönetimi: Garbage Collection, Bellek Sızıntıları ve WeakRef
Stack ve heap'i, erişilebilirliği ve garbage collection'ı anlıyor; unutulan listener, timer, closure ve kopmuş DOM sızıntılarını, WeakMap, WeakRef ile DevTools'ta heap snapshot almayı öğreniyoruz.
Sürüm notu: Bu yazıdaki kodlar ES2026 ve güncel tarayıcılar (Chrome, Edge, Firefox, Safari) içindir.
Önce Tahmin Et
Bir object oluşturalım, onu iki farklı yerde saklayalım, sonra değişkeni null yapalım. Bu kod konsola ne yazdırır?
let user = { name: "Ada" };
const list = [user];
const visits = new WeakMap();
visits.set(user, 3);
user = null;
console.log(list[0].name);
console.log(visits.has(list[0]));
Okumaya devam etmeden önce bir tahmin yap. Bir de ikinci soru: user = null satırından sonra { name: "Ada" } object'i bellekten silinmeye aday mı? Cevabı, garbage collector'ın neye baktığını konuştuktan sonra vereceğiz.
Stack ve Heap: Masa ile Depo
JavaScript'te belleği elle ayırıp elle bırakmazsın; motor bunu senin yerine yapar. Ama ne yaptığını bilmezsen, farkında olmadan belleği şişiren kod yazarsın. Önce iki bölgeyi tanıyalım.
Stack'i bir çalışma masası gibi düşün. Bir fonksiyon çağrıldığında masaya küçük bir çalışma alanı açılır (call stack'teki çerçeve); yerel değişkenler, parametreler ve küçük primitive değerler oraya konur. Fonksiyon bitince o alan topluca kaldırılır, kimseye sormaya gerek yoktur. Heap ise arkadaki depodur: object'ler, array'ler, fonksiyonlar ve closure'ların sakladığı değerler orada durur. Masadaki değişken, object'in kendisini değil, depodaki yerini gösteren bir etiketi tutar.
function createOrder() {
const quantity = 2;
const item = { name: "Pen", price: 15 };
return item;
}
const order = createOrder();
console.log(order.name); // Pen
Fonksiyon döndüğünde çerçevesi masadan kalkar; quantity de item etiketi de gider. Ama heap'teki { name: "Pen", price: 15 } object'i yaşamaya devam eder, çünkü artık order onu gösteriyor. Daha önce gördüğümüz değer ve referans ayrımı tam olarak buradan doğar. Küçük bir dürüstlük notu: motorlar neyi nereye koyacaklarına kendileri karar verir ve bolca optimizasyon yapar. Bu ayrım bir zihinsel model; işi anlamak için yeterince doğru.
Erişilebilirlik: Garbage Collector Neye Bakar?
Depoda neyin atılacağına garbage collector (GC) karar verir ve tek bir soru sorar: "Bu object'e köklerden yola çıkarak ulaşılabiliyor mu?" Kökler, motorun her zaman canlı saydığı başlangıç noktalarıdır: global object, o an çalışan fonksiyonların yerel değişkenleri ve motorun kendi tuttuğu şeyler, örneğin bekleyen bir zamanlayıcının callback'i ya da sayfadaki bir element'e bağlı event listener'lar.
Temel algoritmanın adı mark-and-sweep. GC köklerden başlar, referansları takip ederek ulaştığı her object'i işaretler (mark), sonra işaretlenmemiş her şeyi süpürür (sweep). Bu yüzden döngüsel referanslar sorun değildir:
let a = { name: "a" };
let b = { name: "b" };
a.neighbor = b;
b.neighbor = a;
a = null;
b = null;
// İki nesne hâlâ birbirini gösteriyor ama köklerden ulaşılamıyorlar.
// İkisi de toplanmaya aday.
Modern motorlar bir adım daha atar: object'lerin çoğu genç ölür. Bir fonksiyonun içinde oluşturulup hemen bırakılan geçici object'ler için ayrı, küçük bir alan tutulur ve sık, ucuz turlarla temizlenir. Uzun süre hayatta kalanlar daha seyrek taranan "yaşlı" bölgeye taşınır. Buna generational GC denir. Önemli olan şu: GC'nin ne zaman çalışacağına motor karar verir, JavaScript kodundan onu tetikleyemezsin.
Şimdi baştaki soruya dönelim. Konsola önce Ada, sonra true yazılır. user = null yalnızca bir etiketi kopardı; object'e list array'i üzerinden hâlâ ulaşılabiliyor. Dolayısıyla object silinmeye aday değil ve WeakMap'teki kayıt da yerinde duruyor. null atamak "belleği boşalt" demek değildir, sadece "ben artık bu object'i tutmuyorum" demektir.
Sık Bellek Sızıntıları: Listener ve Timer
Bellek sızıntısı, artık ihtiyacın olmayan ama köklerden hâlâ ulaşılabilen bellektir. GC hata yapmaz; sızıntı her zaman bizim unuttuğumuz bir referanstır. En klasiği unutulan event listener:
function openPanel() {
const bigData = new Array(1_000_000).fill("data");
window.addEventListener("resize", () => {
console.log("Panel data:", bigData.length);
});
}
openPanel her çağrıldığında window'a yeni bir listener eklenir ve her listener kendi bigData'sını closure ile tutar. window sayfa açık olduğu sürece yaşar; panel kapansa bile o array'ler asla toplanmaz. Çözüm, listener'ı işin bitince kaldırmak. Daha önce gördüğümüz AbortController bunu çok temiz yapar:
function openPanel() {
const bigData = new Array(1_000_000).fill("data");
const controller = new AbortController();
window.addEventListener("resize", () => {
console.log("Panel data:", bigData.length);
}, { signal: controller.signal });
return () => controller.abort();
}
const closePanel = openPanel();
// Panel kapanırken:
closePanel();
abort() çağrıldığında bu signal ile eklenmiş bütün listener'lar birlikte kaldırılır. Bir panel beş farklı event dinliyorsa, beşini tek satırla temizlersin.
Zamanlayıcılar da aynı şekilde sızar. setInterval ile kurulan bir callback, sen clearInterval diyene kadar motor tarafından canlı tutulur; callback'in eriştiği her şey de onunla birlikte yaşar:
const state = { messages: [] };
const timer = setInterval(() => {
state.messages.push(new Date().toISOString());
}, 1000);
// Sayfanın bu bölümü kapandığında:
clearInterval(timer);
Bu örnekte iki sorun birden var: zamanlayıcı state'i yaşatıyor ve messages array'i sınırsız büyüyor. Sınırsız büyüyen bir yapı, referans doğru olsa bile zamanla sızıntı gibi davranır.
Sık Bellek Sızıntıları: Closure ve Kopmuş DOM
Closure'lar, daha önce gördüğümüz gibi, tanımlandıkları scope'taki değişkenlere erişimi korur. Uzun yaşayan bir closure büyük bir veriye dokunuyorsa, o veri de uzun yaşar:
function prepareReport() {
const rawData = new Array(1_000_000).fill(42);
return {
summary: () => `Record count: ${rawData.length}`
};
}
const report = prepareReport();
console.log(report.summary()); // rawData, report yaşadıkça bellekte
Bizim ihtiyacımız sadece bir sayı, ama milyon element'li array'i taşıyoruz. Çözüm basit: gerekeni önceden hesapla ve closure'a sadece onu ver. const count = rawData.length; yazıp summary'de count'u kullanırsan array toplanabilir. Motorlar genelde closure'ın gerçekten kullandığı değişkenleri tutar, ancak aynı scope'ta oluşan closure'lar bu bağlamı paylaşabilir. En güvenli yol, büyük veriyi uzun yaşayacak bir closure'ın görüş alanına hiç sokmamak.
Dördüncü klasik ise kopmuş (detached) DOM. Bir element'i sayfadan kaldırdın ama JavaScript'te bir yerde referansını saklıyorsun:
const removed = [];
function deleteRow(row) {
row.remove();
removed.push(row); // "geri al" özelliği için saklıyoruz
}
Element artık ekranda değil, ama removed array'i onu ve altındaki tüm alt ağacı yaşatıyor. Geri al özelliği güzel bir fikir; sorun sınırının olmaması. Son yirmi işlemi tutup gerisini bırakmak ya da kaydı element'in kendisi yerine onu yeniden oluşturacak küçük bir veriyle tutmak çoğu zaman yeterli.
WeakMap ve WeakSet: Object'i Tutmadan Bilgi Eklemek
Daha önce Map ve Set'i anlatırken kısaca işaret ettiğimiz WeakMap, tam bu tür dertler için var. Anahtarlarını zayıf (weak) tutar: anahtar object'e başka hiçbir yerden ulaşılamıyorsa kayıt da onunla birlikte kaybolur. WeakMap anahtarı bir object'i canlı tutmaz.
Bir element'e ait ek bilgiyi element'in kendisine dokunmadan saklamak için birebir. Sayfadaki element'lerin kaç kez tıklandığını sayalım:
const clicks = new WeakMap();
document.addEventListener("click", (e) => {
const counter = clicks.getOrInsertComputed(e.target, () => ({ count: 0 }));
counter.count++;
console.log(e.target.tagName, counter.count);
});
Burada ES2026 ile gelen getOrInsertComputed'ı kullandık: anahtar varsa değerini döndürür, yoksa fonksiyonu çalıştırıp sonucu kaydeder ve onu döndürür. Şubat 2026'dan beri bütün güncel tarayıcılarda var; daha eski tarayıcılar için aynı işi has, get ve set ile yaparsın. Bir element sayfadan kaldırılıp başka hiçbir yerde tutulmadığında, sayacı da kendiliğinden gider. Aynı işi Map ile yapsaydın, silinen her element Map'in içinde sonsuza kadar yaşardı.
WeakSet aynı fikrin set hâli; "bu object'i gördüm mü?" diye işaretlemek için kullanılır. İkisinin de bilinçli sınırları var: anahtarlar object olmalı (ES2023 ile global kayıtta olmayan symbol'lere de izin verildi; Firefox bunu Aralık 2025'te ekledi), gezilemezler, size property'leri yoktur. İçerikleri GC'nin ne zaman çalıştığına bağlı olduğu için motor sana "şu an kaç kayıt var" diye bir cevap vermez.
WeakRef ve FinalizationRegistry: Dikkatli Kullanım
ES2021 ile iki alçak seviyeli araç geldi. WeakRef bir object'i canlı tutmayan bir referanstır; deref() çağırırsın, object hâlâ yaşıyorsa onu, toplanmışsa undefined alırsın. FinalizationRegistry ise kaydettiğin bir object toplandıktan sonra çağrılacak bir callback tanımlamana izin verir. İkisini birlikte, büyük object'leri tutan ama onları zorla yaşatmayan bir önbellekte görelim:
const cache = new Map();
const cleanup = new FinalizationRegistry((key) => {
const ref = cache.get(key);
if (ref && !ref.deref()) cache.delete(key);
});
function fetchCached(key, create) {
const existing = cache.get(key)?.deref();
if (existing) return existing;
const fresh = create();
cache.set(key, new WeakRef(fresh));
cleanup.register(fresh, key);
return fresh;
}
const r1 = fetchCached("january", () => ({ rows: new Array(100_000).fill(0) }));
const r2 = fetchCached("january", () => ({ rows: [] }));
console.log(r1 === r2); // true
Anahtar bir string olduğu için burada WeakMap işe yaramaz; zayıf tutulması gereken şey değer. Kod çalışıyor ama MDN'in de açıkça söylediği gibi bu araçlardan mümkün olduğunca kaçınmak gerekir. Sebepleri şunlar. GC'nin zamanlaması motordan motora, hatta aynı motorda durumdan duruma değişir. Hiçbir şey tutmasa bile bir object hiç toplanmayabilir, yani deref() hiç undefined dönmeyebilir. Bir object'i WeakRef'e koyduğun ya da deref() ile aldığın anda, o object mevcut iş (job) bitene kadar canlı tutulur; aynı senkron akış içinde toplandığını asla göremezsin. FinalizationRegistry callback'i ise geç çalışabilir ya da sayfa kapanırsa hiç çalışmayabilir. Kullanıcı verisini kaydetmek, bağlantı kapatmak gibi mutlaka yapılması gereken işleri asla buraya bağlama.
DevTools Memory Sekmesi ile Heap Snapshot
Teori güzel ama sızıntıyı bulmak için ölçmek gerekir. Kasıtlı olarak sızdıran küçük bir sayfa yazalım. Bunu bir HTML dosyasına kaydedip Chrome ya da Edge'de aç:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Leak hunt</title>
</head>
<body>
<button id="add">Add and remove 1000 rows</button>
<ul id="list"></ul>
<script>
class Row {
constructor(el) {
this.el = el;
}
}
const list = document.querySelector("#list");
const archive = [];
document.querySelector("#add").addEventListener("click", () => {
for (let i = 0; i < 1000; i++) {
const li = document.createElement("li");
li.textContent = `Row ${i}`;
list.append(li);
archive.push(new Row(li));
}
list.replaceChildren();
console.log("Detached elements in archive:", archive.length);
});
</script>
</body>
</html>
Şimdi adım adım ilerleyelim. DevTools'u aç ve Memory sekmesine geç. "Heap snapshot" seçiliyken "Take snapshot" butonuna bas; bu ilk ölçümün. Snapshot almadan önce motor kendisi bir garbage collection çalıştırır, yani listede gördüğün her şey gerçekten erişilebilir durumdadır. Sonra sayfadaki butona üç kez tıkla ve ikinci bir snapshot al.
İkinci snapshot'ta üstteki filtre kutusuna Detached yaz. Adı Detached ile başlayan ve li element'lerini gösteren satırda 3000 element göreceksin (DevTools sürümüne göre Detached <li> ya da Detached HTMLLIElement diye görünür). Bunlar ekranda olmayan ama bellekte duran li'ler. Bir tanesine tıkla; alttaki Retainers panelinde onu kimin tuttuğunu, yani Row object'ini ve onu tutan archive array'ini görürsün. Filtreye Row yazarak kendi class'ını da arayabilirsin; isimli class kullanmanın küçük bir faydası da budur. Görünüm menüsünden "Comparison"ı seçersen iki snapshot arasında nelerin eklendiğini yan yana görürsün. Firefox'un Memory panelinde de snapshot alıp karşılaştırabilirsin; Safari'de benzer bilgi Timelines sekmesindeki JavaScript Allocations kaydında.
Sık Düşülen Tuzaklar
Konsol da referans tutar. console.log ile yazdırdığın object'leri DevTools, sen inceleyebilesin diye canlı tutar. Sızıntı ararken büyük object'leri konsola dökersen, sızıntıyı sen yaratmış olursun. Ölçmeden önce konsolu temizle.
Kazara global değişken. Sloppy mode'da tanımlanmamış bir değişkene atama yapmak global object'e bir property ekler ve o değer sayfa boyunca yaşar. Module'ler ve "use strict" bu hatayı ReferenceError'a çevirir; bu da module kullanmak için güzel bir sebep.
Sınırsız önbellek. "Bir kere hesaplayalım, Map'te tutalım" fikri iyi başlar, sonra Map hiç küçülmez. Bir önbelleğin ya bir boyut sınırı, ya bir süre sınırı, ya da anahtar object'se WeakMap gibi kendiliğinden temizlenen bir yapısı olmalı.
Her şeyi null'lamak. Fonksiyonun içindeki yerel değişkenler fonksiyon bitince zaten kaybolur; onları tek tek null yapmak kodu kirletir, bir şey kazandırmaz. Dikkatini uzun yaşayan yapılara ver: global ya da module seviyesindeki değişkenler, window ve document'a eklenen listener'lar, zamanlayıcılar ve önbellekler.
Küçük Bir Deneme
★☆☆ Çıktıyı tahmin et: let a = { data: "x" }; const ws = new WeakSet([a]); const copy = a; a = null; console.log(ws.has(copy)); Object toplanmaya aday mı? Neden?
★★☆ Sızıntı avı sayfasında archive'ın en fazla son 100 kaydı tutmasını sağla. Aynı adımlarla iki snapshot al ve kopmuş li sayısının nasıl değiştiğine bak.
★★★ Küçük bir HTML sayfasında WeakRef ile bir object'i tut, ona olan normal referansı bırak ve bir FinalizationRegistry callback'i kaydet. Memory sekmesindeki çöp kutusu simgesiyle ("Collect garbage") GC'yi elle tetikle ve callback'in ne zaman çalıştığını gözlemle. Aynı satırda deref() çağırdığında neden hâlâ object'i aldığını açıkla.
Bellek sızıntıları çoğu zaman bir hata mesajıyla değil, zamanla yavaşlayan bir sayfa olarak kendini gösterir. Bir sonraki yazıda yavaşlığın kendisine odaklanacağız: önce ölçmeyi, sonra debounce, throttle, requestAnimationFrame ile ve layout thrashing'den kaçınarak sayfayı hızlandırmayı göreceğiz.