Le prestazioni di una pagina non riguardano solo il caricamento. Un'applicazione web che resta aperta a lungo, come una dashboard, un gestionale, una webmail o un sito a pagina singola in cui l'utente naviga per ore, può diventare via via più lenta senza un motivo apparente. Spesso la causa è una perdita di memoria: oggetti che non servono più ma che il motore JavaScript non può liberare, perché qualcosa continua a farvi riferimento.
Come funziona la memoria in JavaScript
JavaScript gestisce la memoria in automatico. Il garbage collector parte da alcune radici, come l'oggetto globale e le variabili in uso, e segue tutti i riferimenti: ciò che è raggiungibile resta in memoria, ciò che non lo è viene liberato. Una perdita, quindi, non è un errore del motore, ma un riferimento dimenticato nel codice che mantiene raggiungibili oggetti inutili.
I sintomi tipici sono:
- la pagina diventa più lenta con il passare del tempo o dopo molte navigazioni interne;
- le pause per la garbage collection diventano più frequenti e lunghe, visibili nel profilo come blocchi ricorrenti;
- su dispositivi con poca memoria, il browser ricarica la scheda quando l'utente ci torna.
Le cause più comuni
Listener e timer mai rimossi
Un componente che, al montaggio, aggiunge un gestore su window o avvia un setInterval e non li rimuove alla distruzione, continua a vivere. Il listener mantiene un riferimento alla funzione, la funzione al componente, il componente ai suoi dati.
// Da evitare
function montaPannello(el) {
window.addEventListener('resize', () => aggiorna(el));
}
// Meglio
function montaPannello(el) {
const controller = new AbortController();
window.addEventListener('resize', () => aggiorna(el),
{ signal: controller.signal });
return () => controller.abort(); // chiamare allo smontaggio
}
L'uso di AbortController permette di rimuovere in un colpo solo tutti i listener collegati allo stesso segnale.
Nodi DOM staccati
Un elemento rimosso dalla pagina ma ancora referenziato da una variabile JavaScript, una mappa o una cache non può essere liberato, e con lui tutto il suo sottoalbero. È frequente con elenchi ricostruiti a ogni aggiornamento, quando i vecchi elementi restano salvati in un array.
Cache che crescono senza limiti
Una Map usata come cache di risultati, alla quale si aggiungono voci senza mai toglierne, cresce per tutta la durata della sessione. Le soluzioni sono un limite di dimensione con eliminazione delle voci più vecchie, oppure una WeakMap quando la chiave è un oggetto: le voci spariscono da sole quando la chiave non è più referenziata altrove.
Closure che trattengono troppo
Una funzione interna mantiene accesso alle variabili dell'ambito in cui è stata creata. Se un gestore di evento duraturo viene creato all'interno di una funzione che manipola un grande insieme di dati, può trattenere quei dati anche se non li usa direttamente.
Abbonamenti non chiusi
Connessioni WebSocket, osservatori (IntersectionObserver, MutationObserver, ResizeObserver) e abbonamenti a store di stato vanno chiusi o disconnessi quando il componente che li usa scompare.
Trovare la perdita con DevTools
Il pannello Memory di Chrome DevTools offre gli strumenti necessari. Il metodo più affidabile è il confronto tra heap snapshot:
- apri la pagina e porta l'applicazione in uno stato di partenza;
- scatta un primo snapshot;
- ripeti più volte l'azione sospetta, per esempio aprire e chiudere un pannello o navigare tra due sezioni, tornando sempre allo stato di partenza;
- scatta un secondo snapshot;
- seleziona il secondo e scegli la vista di confronto con il primo.
Se l'azione non avesse perdite, dopo ogni ciclo la memoria tornerebbe al livello iniziale. Le classi di oggetti il cui numero cresce proporzionalmente alle ripetizioni sono le candidate.
| Vista dello snapshot | A cosa serve |
|---|---|
| Summary | Oggetti raggruppati per costruttore, con conteggi e dimensioni |
| Comparison | Differenze tra due snapshot |
| Containment | Struttura a partire dalle radici |
| Retainers (riquadro inferiore) | La catena di riferimenti che tiene in vita un oggetto |
Il riquadro dei retainers è il più prezioso: selezionato un oggetto sospetto, mostra chi lo trattiene, fino alla radice. Spesso la catena termina in un listener, in una variabile globale o in una cache, indicando con precisione cosa correggere. Filtrando il riepilogo con la parola Detached si trovano subito i nodi DOM staccati.
Per osservare l'andamento nel tempo, la registrazione delle allocazioni sulla timeline mostra barre blu per la memoria allocata e ancora viva e grigie per quella già liberata: barre blu che si accumulano a ogni ripetizione indicano la perdita.
Prevenire
Nel mio lavoro le perdite si prevengono meglio con abitudini che con le indagini:
- ogni funzione che aggiunge un listener, un timer o un osservatore restituisce una funzione di pulizia;
- i framework offrono punti di smontaggio dei componenti: usali sempre per rilasciare risorse;
- le cache hanno un limite esplicito;
- nei test delle funzioni critiche, ripetere un'azione molte volte e controllare la memoria fa emergere i problemi prima degli utenti.
In sintesi
- Una perdita di memoria è un riferimento dimenticato che tiene in vita oggetti inutili.
- Le cause tipiche sono listener e timer, nodi staccati, cache senza limiti e abbonamenti aperti.
- Confronta heap snapshot prima e dopo aver ripetuto l'azione sospetta.
- Il riquadro dei retainers mostra la catena che trattiene l'oggetto.
- Prevenzione: funzioni di pulizia, smontaggio corretto e cache con limiti.