Mémoire et performance JS/TS - 11 - Détecter et corriger les fuites mémoire

Workflow complet de détection et correction de fuites mémoire : reproduire, mesurer, comparer, corriger, vérifier. Etude de cas Express.

  1. 01 Mémoire et performance JS/TS - 00 - Pourquoi la mémoire compte meme avec un garbage collector
  2. 02 Mémoire et performance JS/TS - 01 - Stack vs Heap
  3. 03 Mémoire et performance JS/TS - 02 - Le cycle de vie de la mémoire
  4. 04 Mémoire et performance JS/TS - 03 - Le garbage collector
  5. 05 Mémoire et performance JS/TS - 04 - V8 en profondeur
  6. 06 Mémoire et performance JS/TS - 05 - Les 6 fuites mémoire classiques
  7. 07 Mémoire et performance JS/TS - 06 - Closures et mémoire
  8. 08 Mémoire et performance JS/TS - 07 - WeakRef, WeakMap et WeakSet
  9. 09 Mémoire et performance JS/TS - 08 - FinalizationRegistry : savoir quand le GC passe
  10. 10 Mémoire et performance JS/TS - 09 - DevTools Memory : investiguer dans Chrome
  11. 11 Mémoire et performance JS/TS - 10 - Profiling mémoire en Node.js
  12. 12 Mémoire et performance JS/TS - 11 - Détecter et corriger les fuites mémoire
  13. 13 Mémoire et performance JS/TS - 12 - ArrayBuffer et TypedArrays
  14. 14 Mémoire et performance JS/TS - 13 - Workers et mémoire partagee
  15. 15 Mémoire et performance JS/TS - 14 - Streams et backpressure
  16. 16 Mémoire et performance JS/TS - 15 - Fuites mémoire en React
  17. 17 Mémoire et performance JS/TS - 16 - Serveurs Node.js et mémoire
  18. 18 Mémoire et performance JS/TS - 17 - Mémoire et Docker
  19. 19 Mémoire et performance JS/TS - 18 - Optimisations mémoire
  20. 20 Mémoire et performance JS/TS - 19 - Comparaison avec d'autres langages
  21. 21 Mémoire et performance JS/TS - 20 - Tester la mémoire
  22. 22 Mémoire et performance JS/TS - 21 - Glossaire

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.

  1. Demarre l'app, laisse-la se stabiliser
  2. Snapshot 1 (baseline)
  3. Execute le scénario une fois
  4. Force GC, Snapshot 2
  5. Execute le scénario une deuxieme fois
  6. 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

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.