Jak wykrywać i usuwać memory leaks w JS

Jak wykrywać i usuwać memory leaks w JS

W artykule opiszę praktyczne sposoby na wykrywanie i usuwanie problemów z pamięcią w aplikacjach JavaScript. Skupię się zarówno na kodzie przeglądarkowym, jak i środowisku Node.js, przedstawię narzędzia, konkretne techniki diagnostyczne oraz wzorce zapobiegające powstawaniu wycieki pamięci. Tekst zawiera przykłady typowych przyczyn, kroki do reproducji problemu oraz zestaw działań naprawczych, które można wdrożyć od ręki.

Czym są wycieki pamięci i dlaczego są groźne

Wycieki pamięci w JavaScript to sytuacje, w których nieużywane już obiekty pozostają referencjonowane i nie są usuwane przez Garbage Collector. Efekt to rosnące zużycie pamięci procesów, spowolnienia, a w skrajnych przypadkach awarie (OOM — out of memory). W aplikacjach webowych objawia się to gorszą responsywnością, zwiększonym czasem ładowania i nieprzyjemnymi skokami w zużyciu zasobów. W serwerowym Node.js może prowadzić do degradacji usług i restartów.

Najczęstsze przyczyny

Poniżej znajdują się typowe źródła problemów, które warto sprawdzić jako pierwsze:

  • Event listeners dodane do elementów DOM lub globalnych obiektów, które nie są usuwane po zakończeniu ich użycia.
  • Timer’y (setInterval, setTimeout) uruchamiane wielokrotnie bez kasowania (clearInterval/clearTimeout).
  • closures przechowujące referencje do dużych struktur danych lub elementów DOM — zamknięcie funkcji może utrzymywać obiekty przy życiu.
  • Odłączone węzły DOM (detached nodes): elementy usunięte z drzewa DOM, ale nadal referencjonowane przez JavaScript.
  • Globalne cache’y i singletony trzymające niepotrzebne dane.
  • Nieprawidłowe użycie bibliotek zewnętrznych lub komponentów UI, które nie wykonują cleanupu przy odmontowaniu.

Wykrywanie wycieków — narzędzia i techniki

Najskuteczniejsze metody łączą obserwację metryk, profilowanie i analizę zrzutów sterty. Poniżej opisane techniki przydadzą się deweloperowi frontendu i backendu.

1) Obserwacja metryk i reprodukcja

  • Rozpocznij od odtworzenia scenariusza, który wywołuje narastanie pamięci (np. intensywne przełączanie widoków, długotrwałe sesje).
  • Monitoruj zużycie pamięci: w przeglądarce użyj Panelu Performance/Memory, w Node.js narzędzi typu top/htop oraz –inspect, a także narzędzi APM.
  • Zapisuj przebieg pamięci w czasie — jeśli wykres rośnie bez spadków po GC, to silny wskaznik wycieku.

2) Heap snapshot i porównania

W Chrome DevTools wybierz zakładkę Memory i wykonaj kilka zrzutów sterty: przed działaniem, w trakcie i po. Porównaj snapshoty, szukając obiektów, których liczba rośnie z każdą iteracją. Zwróć uwagę na:

  • Dominators — obiekty, które utrzymują przy życiu duże fragmenty sterty.
  • Retainers — które inne obiekty referencjonują analizowane obiekty.
  • Typy rosnących obiektów (Node, Array, Closure) — to podpowiedź, gdzie szukać.

3) Allocation instrumentation / Timeline

Narzędzie do rejestrowania alokacji w czasie pozwala zobaczyć momenty, kiedy pojawiają się nowe obiekty. W Chrome: Memory → Allocation instrumentation on timeline. To pomaga powiązać przyrost pamięci z konkretnymi akcjami użytkownika lub funkcjami w kodzie.

4) Szukanie odłączonych węzłów DOM

W snapshotach znajdziesz grupę oznaczoną jako „Detached DOM tree” — jeśli obserwujesz ich wzrost, oznacza to, że elementy DOM są usuwane z widoku, ale nie zwalniane z pamięci.

5) Profilowanie w Node.js

  • Użyj –inspect i narzędzi DevTools do zrzutów sterty z procesu Node.
  • Biblioteki: heapdump (tworzenie zrzutów), clinic/doctor i clinic/heapprofiler, memwatch-next (ostrożnie — może być zaburzające).
  • Sprawdź output GC (node –trace-gc) i monitoruj rss/heapUsed.

Przykłady typowych wycieków i sposoby naprawy

Następnie przejdę przez konkretne scenariusze i pokażę, jak je naprawić.

1) Niezwolnione event listenery

Problem: dodajesz listener do window lub elementu i nie usuwasz go przy odmontowaniu komponentu. Skutek: elementy i powiązane zasoby są trzymane w pamięci.

Naprawa: zarejestruj usuwanie listenerów w odpowiednim miejscu lifecycle, np. w React w cleanup funkcji useEffect lub w komponencie componentWillUnmount. Jeżeli listenery są na obiektach globalnych, zawsze usuwaj je explicite.

2) setInterval bez clearInterval

Problem: timer uruchamia się wielokrotnie i utrzymuje kontekst, zamykając duże obiekty.

Naprawa: zapamiętaj identyfikator timera i wywołaj clearInterval w cleanup. Alternatywnie użyj setTimeout rekurencyjnie, co ułatwia kontrolę żywotności.

3) Closure trzymające DOM lub duże struktury

Problem: funkcje tworzące zamknięcia zachowują referencje do obiektów, nawet jeśli już nie są potrzebne.

Naprawa: usuń odniesienia (przypisz null), ogranicz zakres zmiennych, unikaj globalnego cache’owania niepotrzebnych danych. Tam, gdzie możliwe, przechowuj dane w strukturach, które pozwalają na GC (np. użycie WeakMap dla powiązań obiektów z metadanymi).

4) Biblioteki i komponenty UI

Problem: biblioteka zakłada, że użytkownik wykona cleanup, a on tego nie robi.

Naprawa: sprawdź dokumentację komponentów i upewnij się, że odmontowujesz je poprawnie. W kodzie testuj scenariusze montowania/odmontowywania i obserwuj pamięć.

Techniki naprawcze i narzędzia wspomagające

Poniżej zestaw praktycznych technik, które warto stosować rutynowo:

  • Automatyczne testy obciążeniowe i testy pamięci — uruchamiaj przypadki wielokrotnego montowania komponentów i porównuj zrzuty sterty.
  • Używaj profilowanie w CI dla krytycznych ścieżek aplikacji.
  • W Node.js konfiguruj limit pamięci i mechanizmy restartu (PM2, kube liveness probe) oraz analizuj heap snapshoty po wzroście pamięci.
  • W miejscach, gdzie potrzebujesz przechowywać dane powiązane z obiektami, rozważ WeakMap lub WeakRef (tam, gdzie są wspierane).
  • W React — stosuj cleanup w useEffect, pamiętaj o odsubskrybowaniu z serwisów, socketów i usuwaniu timerów.

Dobre praktyki w projektowaniu kodu, które zapobiegają wyciekom

Profilaktyka jest często skuteczniejsza niż późniejsze debugowanie. Kilka zasad do wdrożenia:

  • Minimalizuj zakresy zmiennych — preferuj lokalne zmienne, unikaj globalnych singletonów z rostrowymi cache’ami.
  • Projektuj API komponentów z jasnymi lifecycle hooks do sprzątania zasobów.
  • Dokumentuj kontrakty dotyczące cleanupu — jeśli komponent zakłada, że użytkownik coś wykona, opisz to wyraźnie.
  • Monitoruj metryki wydajności i pamięci w środowisku produkcyjnym — wczesne alarmy pozwalają reagować zanim użytkownicy zauważą problemy.
  • Regularnie przeglądaj heap snapshoty po wprowadzeniu nowych funkcji, które manipulują DOMem lub dużymi danymi.

Praktyczne checklisty do szybkiej diagnozy

Gdy zauważysz podejrzane narastanie pamięci, wykonaj poniższe kroki:

  • Repro: upewnij się, że potrafisz powtórzyć wzrost pamięci w kontrolowanym scenariuszu.
  • Profil: nagraj timeline i wykonaj co najmniej dwa heap snapshoty: na początku i po serii akcji.
  • Analiza: zidentyfikuj najliczniejsze typy obiektów i ich retainers.
  • Wyizoluj: zacznij wyłączając poszczególne funkcje lub komponenty, by znaleźć winowajcę.
  • Naprawa: usuń niepotrzebne referencje, dodaj cleanup, zastąp silne referencje słabymi (WeakMap), lub zmień architekturę cache.
  • Verify: ponownie przetestuj scenariusz i porównaj snapshoty — pamięć powinna wrócić do linii bazowej po GC.

Przykładowe fragmenty do rozważenia w kodzie

Nawet bez wklejania kodu w artykule warto sprawdzić te miejsca w aplikacji:

  • Funkcje obsługi zdarzeń, które tworzą zamknięcia nad dużymi obiektami.
  • Cache’y trzymane w modułach top-level (np. globalData = {}) — czy są czyszczone?
  • Subskrypcje do EventEmitter, socketów, store’ów — czy są odsubskrybowane na odmontowaniu?
  • Timer’y, requestAnimationFrame — czy są anulowane przy zamykaniu komponentu?

Świadome stosowanie opisywanych technik oraz regularne profilowanie pozwoli znacząco zredukować ryzyko problemów z pamięcią. Pamiętaj też, że narzędzia (takie jak DevTools czy heapdump) dają wskazówki, ale to analiza retainers i logika aplikacji wskazuje, co konkretnie naprawić. Zwracaj uwagę na wzorce użycia bibliotek i lifecycle komponentów — najczęściej to tam kryją się przyczyny problemów.