Tests de performance - 05 - Profiling mémoire : détecter les fuites

Détecter les fuites mémoire dans une app Node.js/Bun. Heap snapshots, process.memoryUsage() et causes courantes.

05 - Profiling mémoire : détecter les fuites

Ce que tu vas apprendre

  • Ce qu'est une fuite mémoire et comment la détecter
  • Utiliser process.memoryUsage() pour monitorer la RAM
  • Les causes les plus courantes de fuites en JavaScript/TypeScript
  • Les heap snapshots pour trouver ce qui fuit

Prerequisites

Connaitre les bases de JavaScript/TypeScript. Avoir Bun installe. Avoir lu l'introduction de la serie.


La fuite invisible

Mars 2025. Mon instance Elysia sur paltemps.fr consommait 120 MB de RAM au démarrage. Normal pour un serveur Bun avec quelques routes. Mais chaque jour, ca montait de 15-20 MB. Au bout de 5 jours, le process pesait 200 MB. Au bout de 10, le conteneur Docker atteignait sa limite de 512 MB et redemarrait.

Le problème ? Un tableau global ou j'accumulais les logs d'acces pour faire des stats. Je pushais dedans a chaque requête et je ne vidais jamais. Classique.

Comment ca se manifeste

Un process JavaScript sain a un pattern mémoire en dents de scie. La mémoire monte (allocations), puis descend (garbage collection), puis remonte, puis redescend. Le GC fait le menage régulièrement et la mémoire oscille autour d'une valeur stable.

Une fuite mémoire, c'est un escalier qui monte. La mémoire monte, le GC passe, mais elle ne redescend pas au meme niveau qu'avant. A chaque cycle, le plancher est un peu plus haut. Au bout de suffisamment de cycles, boom.

Monitorer la mémoire en continu

La première étape, c'est de voir le problème. Ajoute ca a ton serveur :

typescriptsetInterval(() => {
  const mem = process.memoryUsage();
  console.log(
    `Heap: ${(mem.heapUsed / 1024 / 1024).toFixed(1)}MB | RSS: ${(mem.rss / 1024 / 1024).toFixed(1)}MB`
  );
}, 5000);

Toutes les 5 secondes, tu vois la consommation. Lance ton serveur, envoie du trafic pendant 10 minutes, et regarde la tendance. Si heapUsed monte sans jamais redescendre, tu as une fuite.

Pour aller plus loin, ecris un petit logger qui enregistre dans un fichier :

typescriptimport { appendFileSync } from "fs";

const logFile = "/tmp/memory-log.csv";
appendFileSync(logFile, "timestamp,heapUsed,heapTotal,rss,external\n");

setInterval(() => {
  const mem = process.memoryUsage();
  const line = [
    Date.now(),
    mem.heapUsed,
    mem.heapTotal,
    mem.rss,
    mem.external,
  ].join(",");
  appendFileSync(logFile, line + "\n");
}, 5000);

Tu peux ensuite importer le CSV dans un tableur ou un script Python pour tracer la courbe. Si c'est une droite qui monte, tu as ta confirmation.

Les causes courantes

Apres avoir debogue des dizaines de fuites mémoire (les miennes et celles de mes clients), voici les responsables habituels :

Les event listeners oublies. Tu ajoutes un addEventListener ou un .on() a chaque requête, mais tu ne fais jamais removeEventListener. Chaque listener garde une référencé vers sa closure, qui garde une référencé vers les objets qu'elle utilise. Rien n'est libéré.

typescript// Fuite : un nouveau listener a chaque requete
app.get("/stream", (req) => {
  const handler = (data: Buffer) => {
    // traitement
  };
  someEmitter.on("data", handler);
  // jamais de removeListener...
});

Les closures qui capturent des objets lourds. Une closure garde en vie toutes les variables de son scope parent. Si une de ces variables est un gros objet (un buffer de 10 MB, un résultat de requête SQL), cet objet ne sera jamais collecte tant que la closure existe.

typescriptfunction createHandler() {
  const bigData = loadGiantDataset(); // 50 MB en memoire
  return (req: Request) => {
    // bigData est capture par la closure, jamais libere
    return new Response(bigData.length.toString());
  };
}

Les caches sans eviction. Tu implementes un cache avec un objet ou une Map. Tu ajoutes des entrees, tu n'en supprimes jamais. Apres 100 000 requêtes avec des clés différentes, ton cache pese des centaines de MB.

typescript// Fuite : le cache grandit indefiniment
const cache = new Map<string, any>();

function getCached(key: string) {
  if (cache.has(key)) return cache.get(key);
  const value = expensiveComputation(key);
  cache.set(key, value);
  return value;
}

La solution : utilise un LRU cache avec une taille maximale. Quand le cache est plein, l'entree la plus ancienne est supprimee.

Les tableaux globaux qui accumulent. Mon erreur sur paltemps.fr. Un tableau global ou tu push a chaque événement sans jamais le vider.

Heap snapshots : trouver ce qui fuit

Quand tu sais qu'il y a une fuite mais pas d'ou elle vient, les heap snapshots sont ton outil. Avec Bun, tu peux te connecter aux Chrome DevTools :

bashbun --inspect server.ts

Ca affiche une URL ws:// que tu peux ouvrir dans Chrome (chrome://inspect). Dans l'onglet Memory :

  1. Prends un premier snapshot au démarrage
  2. Envoie du trafic pendant 5 minutes (avec k6, voir article 02)
  3. Prends un deuxieme snapshot
  4. Compare les deux snapshots avec la vue "Comparison"

Chrome te montre les objets qui ont ete alloues entre les deux snapshots et qui n'ont pas ete liberes. Trie par "Size Delta" pour voir ce qui a le plus grossi. Tu vas trouver le type d'objet fautif, et en cliquant dessus, la chaîne de retention qui l'empeche d'etre collecte.

Un detecteur simple

Pour du monitoring en continu sans les DevTools, voici un detecteur de fuite basique que j'utilise en staging :

typescriptclass LeakDetector {
  private samples: number[] = [];
  private readonly windowSize = 20;

  check(): { leaking: boolean; trend: number } {
    const mem = process.memoryUsage();
    this.samples.push(mem.heapUsed);

    if (this.samples.length > this.windowSize) {
      this.samples.shift();
    }

    if (this.samples.length < this.windowSize) {
      return { leaking: false, trend: 0 };
    }

    // regression lineaire simplifiee
    const firstHalf =
      this.samples.slice(0, 10).reduce((s, v) => s + v, 0) / 10;
    const secondHalf =
      this.samples.slice(10).reduce((s, v) => s + v, 0) / 10;
    const trend = secondHalf - firstHalf;
    const trendMB = trend / 1024 / 1024;

    return {
      leaking: trendMB > 5, // alerte si +5 MB entre les deux moities
      trend: trendMB,
    };
  }
}

const detector = new LeakDetector();
setInterval(() => {
  const result = detector.check();
  if (result.leaking) {
    console.warn(
      `MEMORY LEAK DETECTED: +${result.trend.toFixed(1)}MB trend`
    );
  }
}, 10000);

C'est rudimentaire, mais ca m'a sauve plusieurs fois. Le vrai profiling passe par les heap snapshots, mais ce detecteur te donne une alerte automatique.

Forcer le GC pour confirmer

Si tu suspectes une fuite, force le garbage collector et regarde si la mémoire descend :

bashbun --smol server.ts

Le flag --smol réduit l'empreinte mémoire de Bun. Tu peux aussi appeler Bun.gc(true) pour forcer une collection complète. Si la mémoire ne baisse pas apres un GC force, c'est bien une fuite (les objets sont encore références quelque part).


Article précédent : 04 - Stress test : trouver le point de rupture Article suivant : 06 - Timeouts et resilience

Sources

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