Déboguer les fuites mémoire dans Node.js
Un processus Node dont l'utilisation mémoire grimpe lentement pendant des heures ou des jours, puis finit tué par manque de mémoire (OOM) par l'orchestrateur de conteneurs, est l'une des classes de bugs les plus frustrantes : il ne se reproduit jamais en local, les logs juste avant le plantage paraissent parfaitement normaux, et « redémarrer le pod » fait disparaître le symptôme sans rien corriger. Voici comment trouver réellement la fuite au lieu de simplement la contourner.
Ce que signifie une « fuite » dans un langage à ramasse-miettes
Node n'a pas de gestion manuelle de la mémoire : une fuite n'est donc pas un free() manquant, c'est un objet encore atteignable depuis un endroit que le ramasse-miettes considère comme une racine (portée globale, fermeture active, structure de données de longue durée) bien après que votre code en a logiquement fini avec lui. Le GC ne peut pas le collecter, car, pour autant qu'il puisse en juger, vous pourriez encore l'utiliser. Le bug est toujours « je détiens encore une référence à ceci », jamais « j'ai oublié de le libérer ».
Les suspects habituels
Les collections qui grossissent sans éviction. Une Map ou un tableau utilisé comme cache, qui ne reçoit que des appels .set()/.push(), jamais de nettoyage :
// leaks: every request adds an entry, nothing ever removes one
const responseCache = new Map();
app.get('/api/report/:id', async (req, res) => {
if (!responseCache.has(req.params.id)) {
responseCache.set(req.params.id, await generateReport(req.params.id));
}
res.json(responseCache.get(req.params.id));
});
Chaque id unique jamais demandé reste en mémoire pour toujours. Corrigez cela avec une borne : un cache LRU qui évince l'entrée la moins récemment utilisée au-delà d'une limite de taille, ou un TTL qui fait expirer les entrées au bout d'un temps fixe.
Les écouteurs d'événements qui survivent à ce à quoi ils sont attachés. L'EventEmitter de Node conserve une référence forte à chaque écouteur enregistré. Si vous attachez un écouteur dans une fonction qui s'exécute à répétition et ne le retirez jamais, les écouteurs s'accumulent, et chacun capture par fermeture toutes les variables qui étaient dans sa portée, les gardant elles aussi en vie.
// leaks: a new listener added every time this runs, old ones never removed
function watchConnection(connection) {
dbPool.on('error', (err) => {
logger.error(`Connection error: ${err.message}`, { connectionId: connection.id });
});
}
Appelez watchConnection 10 000 fois pendant la vie de l'application et dbPool détient 10 000 écouteurs, chacun capturant son propre objet connection. Solution : retirer l'écouteur quand vous en avez fini (emitter.off(event, handler)), ou utiliser emitter.once() s'il ne doit se déclencher qu'une fois.
Les minuteurs jamais effacés. setInterval garde sa fermeture, et tout ce qu'elle référence, en vie aussi longtemps que le minuteur tourne, c'est-à-dire par défaut éternellement.
// leaks if `session` objects are meant to be short-lived
function startHeartbeat(session) {
setInterval(() => {
session.lastSeen = Date.now();
}, 30_000);
// no reference to the interval kept anywhere — it can never be cancelled
}
Si vous ne conservez pas la valeur de retour de setInterval/setTimeout, vous avez perdu la seule poignée permettant d'appeler plus tard clearInterval/clearTimeout : le minuteur et sa fermeture vivent jusqu'à la fin du processus.
Les fermetures sur de gros objets alors qu'un petit morceau suffit. Une fonction qui capture toute une requête ou réponse juste pour utiliser un champ garde l'objet entier (et tout ce que lui référence) en vie tant que la fermeture existe.
// keeps the entire `req` object alive inside `processedLater`
function schedule(req) {
const processedLater = () => console.log(req.user.id);
queue.push(processedLater);
}
// only keeps the one value you actually need
function schedule(req) {
const userId = req.user.id;
const processedLater = () => console.log(userId);
queue.push(processedLater);
}
Confirmer que c'est bien une fuite, et non le dentelé normal du GC
Une utilisation mémoire saine dans Node n'est pas plate : elle monte et descend en dents de scie au fil des passages du GC. Une fuite est une tendance : la base après chaque cycle de GC ne cesse de monter avec le temps, et pas seulement les pics entre les cycles.
setInterval(() => {
const { heapUsed, rss } = process.memoryUsage();
console.log(`heapUsed=${(heapUsed / 1024 / 1024).toFixed(1)}MB rss=${(rss / 1024 / 1024).toFixed(1)}MB`);
}, 60_000);
Journalisez cela pendant quelques heures sous trafic réel. Si heapUsed après chaque creux continue de monter, vous avez une fuite ; s'il oscille autour d'une plage stable, c'est le comportement normal du GC.
Trouver l'objet qui fuit réellement avec des instantanés du tas
Une fois que vous savez qu'il y a une fuite, l'outil pour trouver quoi est --inspect plus le profileur de tas des DevTools de Chrome, en comparant deux instantanés pris à quelques minutes d'intervalle sous charge :
node --inspect server.js
Ouvrez chrome://inspect dans Chrome, connectez-vous au processus et, dans l'onglet Memory :
- Prenez un instantané du tas (référence).
- Laissez l'application tourner sous une charge réaliste pendant quelques minutes.
- Prenez un second instantané.
- Passez la vue de comparaison sur « Comparison » entre les deux : elle trie les objets selon le nombre de nouvelles instances apparues et non collectées.
Le type d'objet en tête de cette comparaison, généralement quelque chose de suspectement précis, comme votre propre classe Session ou un nombre de fermetures qui ne cesse de croître, est presque toujours la fuite. De là, l'arbre des retenteurs (retainer tree) des DevTools montre pourquoi il est encore atteignable : quelle variable, quel tableau ou quelle Map détient la référence qui empêche la collecte. Ce chemin de retention est la vraie réponse : il vous dit exactement quelle ligne de code aller corriger.
Un premier passage moins coûteux : --max-old-space-size et instantanés au moment du plantage
Si vous ne pouvez pas reproduire facilement la fuite dans une session de débogage, vous pouvez obtenir automatiquement un instantané du tas au moment de l'OOM en lançant avec :
node --max-old-space-size=512 --heapsnapshot-near-heap-limit=3 server.js
Cela écrit jusqu'à 3 instantanés du tas sur le disque juste avant que le processus ne plante en atteignant le plafond mémoire : utile en production ou en préproduction, où l'on ne peut pas attacher un débogueur en direct, mais où l'on peut récupérer le fichier .heapsnapshot obtenu et le charger ensuite dans les DevTools pour la même analyse par comparaison.
À retenir
Toute fuite mémoire dans Node se ramène à la même cause racine : quelque chose détient encore une référence dont le code n'a plus besoin. Les caches sans éviction, les écouteurs sans retrait, les minuteurs sans effacement et les fermetures qui capturent plus que nécessaire sont les quatre endroits où cette référence se trouve presque toujours. Confirmez la tendance avec process.memoryUsage() dans le temps, puis trouvez l'objet précis avec une comparaison d'instantanés du tas : deviner à partir des seuls symptômes pointe rarement la vraie ligne de code.