11 - Détecter et corriger les fuites mémoire
Ce que tu vas apprendre
- Le workflow complet en 6 étapes : reproduire, mesurer, comparer, trouver les retainers, corriger, vérifier
- La technique des trois snapshots
- Etude de cas réelle : corriger une fuite dans un middleware Express
- Les patterns de fuite les plus frequents et leurs solutions
Prerequisites
Avoir lu l'article 10 sur le profiling Node.js.
Tu sais maintenant prendre des heap snapshots et lire les metriques mémoire. Mais avoir les outils ne suffit pas. Il faut une méthode. J'ai passe des heures a chercher des fuites sans méthode, a cliquer au hasard dans les snapshots en esperant qu'un truc saute aux yeux. Ca ne marche pas. Voici le workflow qui marche.
Étape 1 : Reproduire
Pas de reproduction, pas de diagnostic. Une fuite mémoire qui "arrive parfois en prod" est inutile a investiguer sans scénario de reproduction.
Pour forcer la reproduction :
- Load test : envoyer des milliers de requêtes avec autocannon ou k6
- Actions repetees : ouvrir/fermer le meme composant 100 fois
- Donnees volumineuses : envoyer des payloads plus gros que d'habitude
bash# autocannon : 1000 requetes, 10 connexions simultanees
npx autocannon -c 10 -a 1000 http://localhost:3000/api/data
Surveille process.memoryUsage().heapUsed pendant le test. Si ca monte sans redescendre, tu as ta reproduction.
Étape 2 : Mesurer (snapshot avant)
Amene l'application dans un état stable. Si c'est un serveur, démarré-le et laisse-le idle quelques secondes. Force un GC :
typescript// Lancer avec : node --expose-gc dist/server.js
if (global.gc) {
global.gc();
}
Prends le premier heap snapshot (via DevTools ou v8.writeHeapSnapshot()). C'est ta baseline.
Étape 3 : Comparer (snapshot apres)
Execute le scénario de reproduction. Attends que tout soit termine. Force un GC. Prends le deuxieme snapshot.
Compare les deux snapshots en vue "Comparison" dans DevTools. Trie par # Delta decroissant. Les types d'objets avec un Delta positif eleve sont tes suspects.
La technique des trois snapshots
C'est une variante plus precise. L'idee : le premier snapshot capture le bruit de fond (caches qui se remplissent au démarrage, lazy initialization). Le vrai signal est entre le deuxieme et le troisieme snapshot.
- Demarre l'app, laisse-la se stabiliser
- Snapshot 1 (baseline)
- Execute le scénario une fois
- Force GC, Snapshot 2
- Execute le scénario une deuxieme fois
- Force GC, Snapshot 3
Compare Snapshot 3 avec Snapshot 2. Les objets qui apparaissent entre les deux sont ceux qui s'accumulent a chaque exécution du scénario. C'est ta fuite, debarrassee du bruit d'initialisation.
typescriptimport v8 from "node:v8";
let snapshotCount = 0;
app.get("/debug/snapshot", (req, res) => {
if (global.gc) global.gc();
const file = v8.writeHeapSnapshot();
snapshotCount++;
res.json({ snapshot: snapshotCount, file });
});
Étape 4 : Trouver les retainers
Tu as identifié les objets suspects (par exemple, +200 Object entre Snapshot 2 et 3). Clique sur un objet, regarde le panneau Retainers. La question est simple : pourquoi cet objet est-il encore en vie ?
Les retainers les plus courants :
- Closure scope : une closure capture une variable et empeche le scope d'etre collecte
- Map/Set/Array global : une collection a laquelle on ajoute sans jamais retirer
- Event listener : un listener enregistre mais jamais retire
- Timer : un setInterval jamais clear
Étape 5 : Corriger
La correction depend du type de fuite. Voici les patterns les plus frequents.
Cache sans limite
typescript// FUITE : le cache grossit sans limite
const cache = new Map<string, object>();
app.get("/api/user/:id", async (req, res) => {
const id = req.params.id;
if (!cache.has(id)) {
cache.set(id, await db.getUser(id));
}
res.json(cache.get(id));
});
// CORRECTION : limiter la taille du cache
const MAX_CACHE = 1000;
app.get("/api/user/:id", async (req, res) => {
const id = req.params.id;
if (!cache.has(id)) {
if (cache.size >= MAX_CACHE) {
const firstKey = cache.keys().next().value;
cache.delete(firstKey!);
}
cache.set(id, await db.getUser(id));
}
res.json(cache.get(id));
});
Ou mieux, utiliser une lib LRU comme lru-cache.
Event listener oublie
typescript// FUITE : listener jamais retire
function setupConnection(socket: WebSocket) {
const handler = (data: MessageEvent) => {
processData(data);
};
socket.addEventListener("message", handler);
// Si cette fonction est appelee a chaque reconnexion,
// les handlers s'empilent
}
// CORRECTION : cleanup explicite
function setupConnection(socket: WebSocket): () => void {
const handler = (data: MessageEvent) => {
processData(data);
};
socket.addEventListener("message", handler);
return () => socket.removeEventListener("message", handler);
}
Closure qui capture trop
typescript// FUITE : la closure capture bigData
function processRequest(bigData: Buffer) {
const id = extractId(bigData);
return () => {
// N'utilise que id, mais bigData est dans le scope
console.log(`Processing ${id}`);
};
}
// CORRECTION : extraire les donnees necessaires avant la closure
function processRequest(bigData: Buffer) {
const id = extractId(bigData);
return createCallback(id);
}
function createCallback(id: string) {
return () => {
console.log(`Processing ${id}`);
};
}
Étape 6 : Verifier
Refais les étapes 2-3 apres la correction. Compare les snapshots. Le Delta doit etre proche de zero pour les types d'objets qui fuitaient.
Si ta fuite etait "heapUsed grimpe de 5 Mo par minute", relance le load test et vérifié que heapUsed se stabilise.
Etude de cas : fuite dans un middleware Express
J'ai eu cette fuite sur un projet réel. Un middleware de logging qui gardait les requêtes en mémoire :
typescript// Le middleware problematique
const requestLog: Array<{ url: string; body: unknown; timestamp: number }> = [];
app.use((req, res, next) => {
requestLog.push({
url: req.url,
body: req.body, // Peut etre enorme (upload de fichiers)
timestamp: Date.now(),
});
next();
});
// Un endpoint de debug pour voir les dernieres requetes
app.get("/debug/requests", (req, res) => {
res.json(requestLog.slice(-100));
});
Le tableau requestLog grossissait a chaque requête. Avec 1000 requêtes par minute et des body de quelques Ko, ca faisait des dizaines de Mo par heure. Et quand un body contenait un fichier uploade en base64, le tableau explosait.
Le retainers tree montrait clairement : requestLog (Array) -> objets avec body contenant des buffers énormes.
La correction :
typescript// Version corrigee avec ring buffer
const MAX_ENTRIES = 100;
const requestLog: Array<{ url: string; timestamp: number }> = [];
app.use((req, res, next) => {
requestLog.push({
url: req.url,
// Plus de body ! Juste l'URL et le timestamp
timestamp: Date.now(),
});
if (requestLog.length > MAX_ENTRIES) {
requestLog.shift();
}
next();
});
Deux corrections : limiter la taille du tableau et ne plus stocker le body. Sur paltemps.fr, les logs de requêtes vont dans un fichier ou dans un service externe, jamais dans un tableau en mémoire.
Check-list anti-fuite
Avant de considérer qu'une fuite est corrigee :
- La mémoire se stabilise apres un load test de 10 minutes
- La comparaison de snapshots montre un Delta proche de zero
- Les retainers des objets suspects ont disparu
- Le correctif a ete teste avec le meme scénario de reproduction
Résumé
- Workflow en 6 étapes : reproduire, mesurer, comparer, trouver retainers, corriger, vérifier
- La technique des trois snapshots elimine le bruit d'initialisation
- Les fuites les plus frequentes : caches sans limite, event listeners oublies, closures trop larges
- Toujours vérifier avec un nouveau cycle de snapshots apres correction
- Les logs en mémoire sont un classique : préférer les fichiers ou services externes
Article précédent : 10 - Profiling mémoire en Node.js Article suivant : 12 - ArrayBuffer et TypedArrays