Mert Özen Her Satırda Daha İleri
Geliştirici Araçları Browser DevTools — #07

Memory Paneli: Heap Snapshot, Allocation Timeline ve Angular'da Memory Leak Avı

Mert Özen 8 Kas 2026 9 dk 3 görüntülenme
Memory Paneli: Heap Snapshot, Allocation Timeline ve Angular'da Memory Leak Avı

Memory panelinde üç snapshot tekniğiyle sızıntıyı kanıtlar, retainer zinciriyle kimin tuttuğunu bulursun: 10 ziyarette 10 BookDetail kaldı, her biri kendi boyutu 0.0 kB iken 201 kB tutuyordu. Window resize listener'ı, paylaşılan closure context'i, service'teki array ve unsubscribe edilmeyen interval; detached DOM'un bütün ağacı tutması, shallow ve retained size, Chrome 155–156'nın context analizi ve takeUntilDestroyed ile düzeltme.

Sürüm notu: Bu yazıdaki örnekler Chrome 156 (156.0.8078.80), Angular 22.2 ve .NET 10 ile denenmiştir. Heap snapshot'lar aynı Chrome sürümünde, headless modda Chrome DevTools Protocol (CDP) ile alındı; Summary, Comparison ve retainer çıktıları DevTools'un heap snapshot motorunu kullanan chrome-devtools-mcp 1.12.0'ın bellek araçlarından. Panel adımları Chrome DevTools dokümanından aktarıldı.

Önce Tahmin Et

Bookstore'un kitap detay sayfası, sayfada geçen süreyi gösteriyor, pencere boyutu değişince bir şey log'luyor ve kendini "son bakılanlar" service'ine kaydediyor:

export class BookDetail implements OnInit {
  readonly id = input.required<string>();
  protected readonly book = signal<Book | undefined>(undefined);
  protected readonly ticks = signal(0);
  private readonly books = inject(BookService);
  private readonly recentlyViewed = inject(RecentlyViewedService);
  private readonly largeCache = new Array(50_000).fill('review-text');

  ngOnInit(): void {
    this.books.getBook(Number(this.id())).subscribe(b => this.book.set(b));
    interval(1000).subscribe(() => this.ticks.update(t => t + 1));
    window.addEventListener('resize', () => this.onResize());
    this.recentlyViewed.register(this);
  }

  private onResize(): void {
    console.log('resize', this.largeCache.length);
  }
}

Kullanıcı on farklı kitabın detayına girip listeye dönüyor. Angular her seferinde component'i destroy ediyor. Garbage collection'dan sonra bellekte kaç BookDetail instance'ı kalır? Ve bir BookDetail'in kendi boyutu ile bellekte tuttuğu toplam boyut arasında kaç kat fark vardır? Bu kodda kaç ayrı sızıntı kaynağı görüyorsun?

Garbage collection ve sızıntının kavramsal tarafı JavaScript İleri serisinin "JavaScript Bellek Yönetimi: Garbage Collection, Bellek Sızıntıları ve WeakRef" yazısında. Bu yazı araç odaklı: sızıntıyı Memory paneliyle nasıl kanıtlayıp nasıl bulacağımız.

Heap Snapshot: Summary, Comparison ve Containment

Memory panelinde profil tipini Heap snapshot seçip Take snapshot'a basarsın. DevTools önce bir garbage collection tetikler, sonra o andaki JavaScript heap'ini ve ona bağlı DOM (Document Object Model) nesnelerini dosyaya döker. Snapshot açıldığında üstteki menüden görünüm seçersin. Summary, object'leri constructor adına göre gruplar: _BookDetail, Array, (string) gibi. Comparison iki snapshot arasında her constructor için eklenen, silinen ve farkı listeler. Containment heap'i köklerden, yani window'dan ve garbage collection (GC) root'larından aşağı doğru gezmeni sağlar. Statistics ise bellek dağılımının özetidir.

Summary'deki iki kolon bu panelin özüdür: shallow size object'in kendi boyutu, retained size ise o object toplandığında serbest kalacak toplam bellek. On ziyaretten sonra alınan snapshot'ta _BookDetail satırı:

id,className,count,selfSize,retainedSize
109,_BookDetail,10,0.4 kB,2,009 kB

On instance, toplam kendi boyutları 0.4 kB, ama tuttukları bellek 2 MB. Satırı açınca her instance tek tek gelir:

nodeId,nodeName,type,distance,selfSize,retainedSize
2068547,_BookDetail,object,14,0.0 kB,201 kB
3644325,_BookDetail,object,14,0.0 kB,201 kB

Kendi boyutu yuvarlanınca 0.0 kB görünen bir component 201 kB tutuyor. Farkın nereden geldiğini object'in giden edge'leri söylüyor: largeCache property'si 200 kB'lık bir Array. Shallow size'a bakıp "component küçük, sorun değil" demek bu yüzden yanıltıcı. Distance kolonu ise object'in GC root'a kaç adım uzakta olduğu; leak avında küçük distance'lı beklenmedik object'ler ilk şüphelilerdir.

Üç Snapshot Tekniği

Tek bir snapshot "bellekte ne var" der, "ne birikiyor" demez. Klasik yöntem üç snapshot: başlangıçta bir tane al, şüpheli akışı birkaç kez tekrarla, ikinci snapshot'ı al, akışı aynı sayıda tekrar et, üçüncüyü al. Birinci ile ikinci arasındaki fark ısınmadır (cache'ler dolar, lazy chunk'lar yüklenir); ikinci ile üçüncü arasında sayısı tekrar sayısıyla orantılı artan şey sızıntıdır. Bookstore'da akış "detaya gir, listeye dön":

[leak] after 5 visits: BookDetail instances=5, usedJSHeapSize=33.2 MB
[leak] after 10 visits: BookDetail instances=10, usedJSHeapSize=34.2 MB
[leak] console after one resize: 10 resize logs

Instance sayısı ziyaret sayısıyla birebir artıyor: hiçbiri toplanmamış. Pencere boyutunu bir kez değiştirince konsola on satır düşüyor; yani destroy edilmiş on component hâlâ event dinliyor. Sayıyı konsoldan queryObjects(BookDetail) ile de görebilirsin; biz aynı şeyi CDP'nin Runtime.queryObjects'iyle yaptık.

Comparison görünümünde ikinci ve üçüncü snapshot'ı karşılaştırınca ilk satırlarda (array), (compiled code), PerformanceEventTiming gibi gürültü görürsün; tarayıcının kendi kayıtları da her tıklamada büyür. Leak'i bulmak için Class filter kutusuna kendi class adını yaz ya da listeyi count delta'ya göre sırala ve tekrar sayısının (burada 5) katı olan satırları ara. Bizim karşılaştırmamızda toplam fark +959 kB çıktı.

Retainer Zinciri: Kim Tutuyor?

Summary'de bir instance'ı seçince alttaki Retainers bölümü onu tutan referansları gösterir; zinciri GC root'a kadar takip edersin. Sızan bir BookDetail'in doğrudan retainer'ları:

name,type,nodeId,nodeName,selfSize,retainedSize
2,internal,2669907,(object elements),0.1 kB,0.1 kB
2,element,2669877,Array,0.0 kB,0.1 kB
this,context,2036839,system / Context / scope @1831819,0.0 kB,0.0 kB

İki ayrı tutan var: bir array'in 2 numaralı element'i ve bir closure context'inin this'i. Context'in GC root'a giden yolu (en kısası, kırpılmış):

<- @2036839 system / Context / scope @1831819 via context this (distance: 13)
  <- @2036837  via internal context (distance: 12)
    <- @14804 V8EventListener via element 1 (distance: 11)
      <- @541048 EventListener via element 1 (distance: 10)
        <- @541050 blink::RegisteredEventListener via element 1 (distance: 9)
          ...
                <- @166322 blink::EventTargetData via element 1 (distance: 5)
                  <- @65059 Window / http://localhost:4391 via element 26 (distance: 4)

07-retainer-chain-tr.png

Zinciri sağdan sola oku: Window'un event listener listesinde bir resize listener'ı var; listener bir arrow function; arrow function ngOnInit'in scope'unu (context) tutuyor; context'te this var, o da BookDetail. Array ise RecentlyViewedService.viewers. Bir object, kökten ona giden bütün yollar kesildiğinde toplanır; yalnız birini düzeltirsen instance sayısı değişmez. Bu yüzden düzeltmeden sonra mutlaka yeniden ölç.

Context'in ayrıntısı önemli. ngOnInit içindeki iki arrow function (interval'in callback'i ve resize listener'ı) aynı scope'ta yaratıldığı için V8 (Chrome'un JavaScript engine'i) onlara tek bir context veriyor. Interval'i düzeltip listener'ı unutursan context yine yaşar, yine this'i tutar. Chrome 155–156 tam bu durum için bir analiz ekledi: heap snapshot'taki V8 scope bilgisini okuyup, paylaşılan bir context yüzünden canlı kalan ama artık kullanılmayan closure değişkenlerini bellek etkisine göre listeliyor. Bunu Summary'nin filtre menüsünde "Objects retained by contexts" seçerek görürsün. Aynı menüde "Objects retained by detached DOM nodes", "Objects retained by the DevTools console" ve "Objects retained by event handlers" filtreleri de var.

Chrome 153–154 ile retainer ve edge listeleri minimum retained size'a göre filtrelenip retained size, self size ya da ada göre sıralanabiliyor; snapshot'taki object'leri retained size eşiğine ya da property adına göre doğrudan sorgulamak da mümkün. Bozuk bir snapshot dosyası da artık parser'ı kilitlemek yerine açık bir hata veriyor.

Detached DOM: Bir Referans Bütün Ağacı Tutar

Detached DOM element, sayfadan çıkarılmış ama JavaScript'ten hâlâ referans verilen element'tir. Bu yazının ölçümünü hazırlarken buna kendimiz düştük. İlk script'imiz her ziyarette bir kitap kartını bekleyip, dönen element referansını hiç bırakmıyordu. İkinci ve üçüncü snapshot'ın karşılaştırması:

index,className,addedCount,removedCount,countDelta,addedSize,removedSize,sizeDelta
0,Detached blink::UniqueElementData,60020,0,60020,6,722 kB,0.0 kB,6,722 kB
1,Detached Text,40020,0,40020,3,842 kB,0.0 kB,3,842 kB
2,Detached <span>,20000,0,20000,2,080 kB,0.0 kB,2,080 kB
3,Detached <button>,10000,0,10000,1,760 kB,0.0 kB,1,760 kB
6,Detached <app-book-card>,10000,0,10000,1,040 kB,0.0 kB,1,040 kB

Beş ziyarette tutulan beş referans, 10.000 detached app-book-card demek: her referans tek bir kartı tutuyor ama detached bir element parent'ını, parent'ı da bütün alt ağacı tutar, yani listedeki 2000 kartın hepsi kalır. Toplam fark +22.429 kB'tı; referansları bırakınca +959 kB'a indi. Angular'da aynı şey bir service'e ya da global bir değişkene koyduğun ElementRef, @ViewChild sonucu ya da document.querySelector sonucuyla olur. Summary'nin Class filter kutusuna Detached yazmak bu tür sızıntıları tek hamlede listeler.

Allocation Timeline ve Allocation Sampling

Snapshot "ne kaldı" sorusunu cevaplar; "ne zaman ve kim ayırdı" için diğer iki profil tipi var. Allocations on timeline kayıt boyunca ayrılan belleği zaman çizgisinde çubuklar olarak gösterir: mavi çubuklar kaydın sonunda hâlâ yaşayan, gri çubuklar toplanmış ayırmalardır. Detaya girip çıktığın her an için bir mavi çubuk kalıyorsa leak oradadır; çubuğu seçince o aralıkta ayrılıp hâlâ yaşayan object'ler listelenir ve allocation stack'leri görünür. Allocation sampling ise düşük maliyetli bir örnekleme yapar ve belleği hangi fonksiyonların ayırdığını gösterir; uzun süren bir oturumda ya da production build'de daha uygundur. Bizim örnekte ayırmaların sahibi belli olduğu için snapshot karşılaştırması yetti; ama "bellek artıyor, neyin arttığını bilmiyorum" durumunda timeline'la başla.

Angular'da Dört Klasik Leak ve Düzeltmesi

Önce Tahmin Et'teki kodda üç kaynak vardı; dördüncüsü onun ilacıyla ilgili:

  • Abonelikten çıkılmayan observable: interval sonsuz bir stream; component ölse de timer yaşar ve callback'i this'i tutar.
  • Global event listener: window'a eklenen listener, kaldırılmadıkça component'i tutar.
  • Service içindeki koleksiyon: root service uygulama boyunca yaşar; içine konan component asla toplanmaz.
  • Template'te async pipe yerine elle subscribe: async pipe ve toSignal aboneliği component'in ömrüne bağlar, elle yazılan abonelik bağlamaz.

Düzeltilmiş ngOnInit:

ngOnInit(): void {
  this.books.getBook(Number(this.id())).subscribe(b => this.book.set(b));
  interval(1000)
    .pipe(takeUntilDestroyed(this.destroyRef))
    .subscribe(() => this.ticks.update(t => t + 1));
  const onResize = () => this.onResize();
  window.addEventListener('resize', onResize);
  this.recentlyViewed.register(this);
  this.destroyRef.onDestroy(() => {
    window.removeEventListener('resize', onResize);
    this.recentlyViewed.unregister(this);
  });
}

takeUntilDestroyed aboneliği DestroyRef'e bağlıyor; listener'ın referansı bir değişkende tutuluyor ki aynı fonksiyonla kaldırılabilsin; service'e de unregister eklendi. HTTP (HyperText Transfer Protocol) isteğinin aboneliğine dokunmadık: HttpClient observable'ı yanıt gelince tamamlanır. Aynı ölçüm düzeltmeden sonra:

[fixed] after 5 visits: BookDetail instances=0, usedJSHeapSize=32.2 MB
[fixed] after 10 visits: BookDetail instances=0, usedJSHeapSize=32.3 MB
[fixed] console after one resize: 0 resize logs

Sıfır instance. Service'e component koymak yerine yalnız gereken veriyi (kitap id'si gibi) saklamak daha da iyi bir tasarım; takeUntilDestroyed ilk kez Angular serisinin "Angular 16 Yenilikleri: Signals, Required Input ve takeUntilDestroyed" yazısında anlatılmıştı.

Üretimde Dikkat

Snapshot'ı development build'de al: class adları okunur (_BookDetail), production'da minify edilmiş adlarla (Tr, pa) uğraşırsın. Ama mutlak sayıları production build'de doğrula. Snapshot dosyaları büyüktür; bizim dev build'deki snapshot'larımız 100 ile 122 MB arasındaydı ve sayfadaki her string'i, dolayısıyla kullanıcı verisini ve token'ları içerebilir. Paylaşmadan önce bunu hesaba kat.

Ölçerken kendi araçların da retainer olur. Konsola log'ladığın object'ler, queryObjects sonuçları ve Store as global variable ile oluşan temp1 değişkenleri DevTools console tarafından tutulur. Bizim bir ara denememizde retainer listesinin başında "DevTools console" çıktı; sebep kendi sayım sorgumuzdu. Ölçümden önce konsolu temizle.

Kullanıcının makinesinde Memory paneli yok. Uzun yaşayan bir sekmede bellek büyümesini fark etmek için performance.measureUserAgentSpecificMemory() gibi API'ler (Application Programming Interface) ve RUM (Real User Monitoring) metrikleri var; ama teşhis yine burada, yerelde, tekrar edilebilir bir akışla yapılır.

Sık Düşülen Tuzaklar

  • Shallow size'a bakıp küçük görünen object'i temize çıkarmak; retained size'a bak.
  • Tek snapshot'la karar vermek: ısınma ile sızıntıyı ayırmak için üç snapshot gerekir.
  • Bir retainer yolunu kesip ölçmeden "düzeldi" demek; diğer yollar object'i tutmaya devam eder.
  • Paylaşılan closure context'ini unutmak: aynı scope'taki başka bir callback this'i yaşatır.
  • Konsol log'larını, temp değişkenlerini ve test aracının element referanslarını leak sanmak.
  • Arrow function'ı removeEventListener'a yeniden yazmak: farklı bir fonksiyon olduğu için listener kalkmaz.

Küçük Bir Deneme

★☆☆ Development build'de bir snapshot al, Class filter'a BookDetail yaz. Detaya üç kez girip çık, ikinci snapshot'ı al ve Comparison görünümünde _BookDetail satırının count delta'sını oku.

★★☆ Yalnız takeUntilDestroyed'ı ekle, listener'ı ve service kaydını olduğu gibi bırak. Üç snapshot tekniğini tekrarla ve instance sayısının neden değişmediğini retainer zincirinden göster. Ardından "Objects retained by contexts" filtresine bak.

★★★ RecentlyViewedService'i component yerine yalnız kitap id'lerini tutacak şekilde yeniden yaz. Allocations on timeline ile detay sayfasına beş kez girip çıkarken kayıt al; kayıt sonunda kalan mavi çubukların neye ait olduğunu bul ve düzeltmeden önceki kayıtla karşılaştır.

Bellek temiz, etkileşim hızlı; ama bunları kim ölçüyor ve hangi sayıya güvenmeli? Bir sonraki yazıda Lighthouse 13'ün audit'lerini doğru okumayı, lab ve field verisi arasındaki farkı, Rendering sekmesinin emülasyonlarını ve Coverage ile Angular bundle'ında @defer ve lazy route adaylarını bulmayı ele alıyoruz.