05 - Les 6 fuites mémoire classiques
Ce que tu vas apprendre
- Les 6 patterns de fuites mémoire les plus frequents en JS/TS
- Comment les détecter
- Comment les corriger (avec du code avant/apres)
Prerequisites
- Avoir lu V8 en profondeur
- Connaitre les bases du DOM, des closures, et des timers
La fuite mémoire, c'est pas un crash
Une fuite mémoire ne plante pas ton application. Pas tout de suite. Elle la ralentit, progressivement, insidieusement. La mémoire monte de 50 Mo a 100 Mo, puis 200, puis 500. L'utilisateur ne comprend pas pourquoi son onglet rame. Le dev ne comprend pas pourquoi le serveur Node.js a besoin de 2 Go de RAM pour servir 100 requêtes par seconde.
Voici les 6 patterns que je vois le plus souvent.
1. Le timer oublie
Le classique absolu. Un setInterval qui tourne jusqu'a la fin des temps.
typescript// FUITE : le timer n'est jamais nettoye
function startPolling(url: string) {
const data: string[] = [];
setInterval(async () => {
const response = await fetch(url);
const json = await response.json();
data.push(json); // data grandit indefiniment
}, 5000);
}
startPolling("/api/status");
// Meme si tu n'as plus besoin du polling,
// le setInterval continue, data grossit, la closure retient tout.
typescript// CORRIGE : on retourne le moyen d'arreter
function startPolling(url: string) {
const data: string[] = [];
let active = true;
const intervalId = setInterval(async () => {
if (!active) return;
const response = await fetch(url);
const json = await response.json();
data.push(json);
// On limite la taille
if (data.length > 100) {
data.splice(0, data.length - 100);
}
}, 5000);
return () => {
active = false;
clearInterval(intervalId);
};
}
const stopPolling = startPolling("/api/status");
// Plus tard :
stopPolling();
2. Les event listeners non retires
Chaque addEventListener créé une référencé vers le callback. Si le callback capture du scope, tout ce scope est retenu.
typescript// FUITE : le listener n'est jamais retire
class ChatWidget {
private messages: string[] = [];
mount() {
document.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
this.messages.push("nouveau message");
this.render();
}
});
}
destroy() {
// On retire le widget du DOM mais le listener reste
this.element.remove();
// Le listener sur document retient "this" (le ChatWidget)
// qui retient this.messages, this.element, etc.
}
}
typescript// CORRIGE : on garde une reference au handler pour le retirer
class ChatWidget {
private messages: string[] = [];
private handleKeydown: (event: KeyboardEvent) => void;
mount() {
this.handleKeydown = (event: KeyboardEvent) => {
if (event.key === "Enter") {
this.messages.push("nouveau message");
this.render();
}
};
document.addEventListener("keydown", this.handleKeydown);
}
destroy() {
document.removeEventListener("keydown", this.handleKeydown);
this.element.remove();
this.messages = [];
}
}
3. Les closures qui retiennent trop
Une closure capture le scope de sa fonction parente. Si ce scope contient un gros objet, la closure le retient.
typescript// FUITE : la closure retient bigData meme si elle ne l'utilise pas directement
function createHandler() {
const bigData = new Array(1_000_000).fill("x");
// V8 est intelligent et n'inclut dans la closure que
// les variables reellement utilisees... en general.
// Mais si tu utilises eval() ou des patterns dynamiques,
// V8 capture tout le scope.
return function handler() {
// On n'utilise pas bigData ici, mais dans certains cas
// V8 ne peut pas prouver qu'il n'est pas utilise
console.log("handled");
};
}
typescript// CORRIGE : on isole les donnees temporaires
function createHandler() {
const processedResult = processData(); // on traite et on jette
return function handler() {
console.log(processedResult); // on ne garde que le resultat
};
}
function processData() {
const bigData = new Array(1_000_000).fill("x");
return bigData.length; // on retourne juste ce dont on a besoin
}
4. Les variables globales accidentelles
En mode non-strict, oublier let, const ou var créé une variable globale. En TypeScript, c'est moins frequent, mais ca arrive via window ou globalThis.
typescript// FUITE : variable globale accidentelle (JS non-strict)
function processData(items) {
results = items.map(transform); // oops, pas de let/const
// "results" est maintenant window.results ou global.results
// Il ne sera JAMAIS libere par le GC
}
typescript// CORRIGE : toujours declarer les variables
"use strict"; // ou utilise TypeScript / des modules ES
function processData(items: Item[]) {
const results = items.map(transform);
return results;
}
En TypeScript avec strict: true dans tsconfig.json, ce genre d'erreur est attrape a la compilation. Raison de plus pour activer le mode strict.
5. Les noeuds DOM detaches
Tu retires un élément du DOM, mais tu gardes une référencé en JavaScript. L'élément (et tout son sous-arbre) reste en mémoire.
typescript// FUITE : noeud DOM detache
class TableManager {
private rows: HTMLTableRowElement[] = [];
addRow(data: string) {
const row = document.createElement("tr");
row.innerHTML = `<td>${data}</td>`;
this.table.appendChild(row);
this.rows.push(row); // on garde une reference
}
clearTable() {
this.table.innerHTML = ""; // les lignes sont retirees du DOM
// Mais this.rows contient encore les references !
// Chaque <tr> (et ses enfants) reste en memoire
}
}
typescript// CORRIGE : on vide aussi le tableau de references
class TableManager {
private rows: HTMLTableRowElement[] = [];
addRow(data: string) {
const row = document.createElement("tr");
row.innerHTML = `<td>${data}</td>`;
this.table.appendChild(row);
this.rows.push(row);
}
clearTable() {
this.table.innerHTML = "";
this.rows = []; // on libere les references
}
removeRow(index: number) {
const row = this.rows[index];
row.remove(); // retire du DOM
this.rows.splice(index, 1); // retire de notre tableau
}
}
6. Les collections qui ne font que grandir
Un Map, un Set, un Array global qui accumule des entrees sans jamais en retirer. C'est la fuite que j'avais sur le serveur de webhooks de l'introduction.
typescript// FUITE : le Map grandit sans limite
const userSessions = new Map<string, SessionData>();
function onUserConnect(userId: string) {
userSessions.set(userId, {
connectedAt: Date.now(),
data: loadUserData(userId),
});
}
function onUserDisconnect(userId: string) {
// Oops, on a oublie de retirer l'entree
console.log(`${userId} disconnected`);
}
typescript// CORRIGE : on nettoie les entrees
const userSessions = new Map<string, SessionData>();
function onUserConnect(userId: string) {
userSessions.set(userId, {
connectedAt: Date.now(),
data: loadUserData(userId),
});
}
function onUserDisconnect(userId: string) {
userSessions.delete(userId); // on libere la reference
}
// Bonus : un nettoyage periodique pour les sessions zombie
setInterval(() => {
const now = Date.now();
const ONE_HOUR = 60 * 60 * 1000;
for (const [userId, session] of userSessions) {
if (now - session.connectedAt > ONE_HOUR) {
userSessions.delete(userId);
}
}
}, 60_000);
Le pattern commun
Si tu regardes bien, les 6 fuites ont le meme schema :
[Objet cree] ---reference---> [Stocke quelque part de durable]
(global, timer, listener, cache)
|
v
[Jamais retire]
|
v
[GC ne peut pas liberer]
La regle d'or : tout ce que tu alloues dans un contexte durable, tu dois le libérer explicitement. Le GC ne gere que les objets temporaires qui sortent naturellement du scope.
Sur paltemps.fr, j'avais les fuites 1, 2 et 6 en meme temps. Un setInterval pour actualiser les previsions, des listeners sur les onglets meteo, et un cache de requêtes API sans TTL. Trois patterns, un meme symptome : la mémoire qui monte lentement en prod.
Résumé
- Timer oublie : toujours stocker l'ID et appeler
clearInterval/clearTimeout. - Listener non retire : garder la référencé au handler, appeler
removeEventListenerau demontage. - Closure trop large : isoler les gros objets dans des fonctions separees.
- Variable globale : utiliser
"use strict"et TypeScript strict. - DOM detache : vider les références JS quand on retire des éléments du DOM.
- Collection qui grandit : supprimer les entrees quand elles ne sont plus utiles, mettre un TTL.
Precedent : V8 en profondeur Suivant : Closures et mémoire