Une fuite mémoire Node.js se reconnaît à un plancher de heap qui monte de cycle de garbage collection en cycle, là où un processus sain dessine une dent de scie qui revient toujours à la même base. Pour la confirmer, suivez process.memoryUsage() dans vos logs, capturez deux heap snapshots sous charge, puis comparez-les dans Chrome DevTools pour lire la chaîne de rétention. La plupart des fuites en production viennent de caches non bornés, de listeners ajoutés à chaque requête, de closures capturées ou de timers oubliés. Voici la méthode que nous appliquons quand un service commence à dériver.
Comment distinguer une vraie fuite du comportement normal du GC ?
Un processus Node.js sain dessine une dent de scie : heapUsed monte entre deux garbage collections, puis retombe à peu près au même plancher. Une fuite se voit à un plancher qui monte de cycle en cycle : chaque collection libère moins que la précédente. Jugez le minimum après full GC sur plusieurs heures, jamais sur des valeurs instantanées.
Lisez ensuite les bons compteurs. process.memoryUsage() expose rss, heapTotal, heapUsed, external et arrayBuffers. Un plancher heapUsed qui monte signifie que des objets JavaScript sont retenus. Un RSS qui grimpe avec un heap stable pointe hors de V8 : Buffers, addons natifs ou mémoire ArrayBuffer non libérée. Loguez les cinq champs chaque minute et tracez-les : la forme de la courbe réduit le champ de recherche avant même d'ouvrir un profileur.
| Symptôme | Cause probable | Première vérification |
|---|---|---|
RSS qui monte, heapUsed stable | Mémoire native ou externe (Buffers, addons) | external et arrayBuffers dans process.memoryUsage() |
Plancher heapUsed qui monte de cycle GC en cycle | Objets JS retenus (cache, closures, listeners) | Comparer deux heap snapshots dans DevTools |
| OOM après N requêtes | Accumulation par requête | Corréler la mémoire au compteur de requêtes, inspecter les listeners par requête |
| OOM Lambda après réutilisation à chaud | Accumulation au niveau module | Auditer l'état de niveau module, tracer la mémoire par invocation |
Quelles fuites reviennent le plus souvent ?
Le classement est stable dans notre pratique : les caches et les Map non bornés d'abord, les listeners ajoutés à chaque requête et jamais retirés ensuite, puis les closures qui capturent de gros objets dans des portées à longue durée de vie, les timers jamais annulés, et l'accumulation au niveau module en serverless. Vérifiez-les dans cet ordre avant de sortir un profileur.
Les deux premiers partagent une signature : la mémoire suit le trafic, pas le temps. Une Map utilisée comme cache n'a aucune politique d'éviction : chaque clé distincte devient un résident permanent. Les listeners sont plus sournois : attacher un handler à un émetteur partagé dans un handler de requête garde en vie la closure de chaque requête, et Node.js affiche un MaxListenersExceededWarning bien avant le crash. Traitez cet avertissement comme un rapport de fuite, pas comme du bruit.
// Fuite : une entrée par clé distincte, conservée pour toujours
const cache = new Map();
async function getUser(id) {
if (!cache.has(id)) cache.set(id, await db.users.findById(id));
return cache.get(id);
}
// Correctif : remplacer la Map par un cache borné
import { LRUCache } from 'lru-cache';
const cache = new LRUCache({ max: 5000, ttl: 60_000 });
Closures et timers suivent la même logique : un setInterval jamais annulé retient indéfiniment tout ce que son callback référence.
Comment prendre un heap snapshot en production sans risque ?
Deux voies supportées : démarrer le processus avec --heapsnapshot-signal puis lui envoyer un signal, ou appeler v8.writeHeapSnapshot() depuis une route d'admin protégée. Les deux bloquent le processus pendant l'écriture, et la documentation Node.js prévient que l'opération peut demander environ deux fois la taille du heap : capturez donc une instance sortie de la rotation.
# Option 1 : activer au démarrage, déclencher par signal
node --heapsnapshot-signal=SIGUSR2 server.js
kill -USR2 <pid> # écrit Heap.*.heapsnapshot à côté du processus
// Option 2 : route d'admin protégée, jamais publique
import v8 from 'node:v8';
app.post('/admin/heap-snapshot', requireAdmin, (req, res) => {
const file = `/tmp/heap-${process.pid}-${Date.now()}.heapsnapshot`;
v8.writeHeapSnapshot(file); // bloque l'event loop pendant l'écriture
res.json({ file });
});
Prenez le premier snapshot une fois le service chaud, gardez la charge stable, puis prenez le second quand la mémoire a visiblement grossi, typiquement trente minutes ou quelques milliers de requêtes plus tard. Rapatriez les deux fichiers hors du serveur : ils contiennent vos données, traitez-les comme sensibles.
La mémoire d'un de vos services Node.js grimpe sans explication ? Décrivez votre système : diagnostic d'une page sous 48 h.
Recevoir mon diagnostic →Comment comparer deux snapshots dans Chrome DevTools ?
Chargez les deux fichiers dans le panneau Memory de DevTools, sélectionnez le plus récent, puis passez la vue sur « Objects allocated between Snapshot 1 and Snapshot 2 ». Triez par retained size, dépliez le plus gros constructeur et lisez le panneau Retainers : il nomme la chaîne de références exacte, souvent une Map ou un tableau de listeners, qui garde ces objets en vie.
Remontez cette chaîne jusqu'à reconnaître un fichier de votre code, puis appliquez la boucle que nous utilisons sur chaque mission : mesurer, snapshot à T et T+N sous charge, comparer, corriger la rétention, déployer, re-mesurer. Le plancher doit redevenir plat en une release. Si les allocations sont trop bruyantes, le heap profiler de clinic.js est un complément raisonnable. Ce qui n'aide pas : augmenter --max-old-space-size. Cela repousse l'OOM, allonge les pauses GC et masque la tendance que vous devez voir. Le guide mémoire de Chrome DevTools documente le panneau en détail, et cette boucle mesurer-comparer est le cœur de nos missions d'optimisation de performance.
Pourquoi une Lambda finit-elle en OOM après N invocations ?
Parce que les environnements d'exécution sont réutilisés. La plateforme gèle votre processus entre deux invocations et le dégèle pour la suivante : l'état de niveau module survit aux démarrages à chaud. Une petite accumulation par invocation grossit jusqu'à ce que l'environnement meure en OOM après N invocations, puis un cold start le réinitialise et le cycle recommence en silence.
Les signes qui trahissent : une métrique mémoire qui grimpe d'invocation chaude en invocation chaude et retombe après chaque cold start, et des erreurs OOM qui arrivent par paquets plutôt qu'au hasard. Gardez volontairement les clients coûteux (connexions base de données, instances de SDK) au niveau module, c'est exactement à cela que sert la réutilisation à chaud, mais n'y stockez jamais rien d'indexé par requête. Les données par requête vivent dans le handler, ou dans un cache borné avec TTL si elles doivent être partagées.
Comment empêcher les fuites de revenir ?
Bornez tout ce qui grossit. Les caches reçoivent une taille maximale et un TTL (le paquet lru-cache est la réponse standard), les tables indexées par objet passent en WeakMap pour que les entrées meurent avec leurs clés, et chaque enregistrement de listener est apparié à un retrait, idéalement piloté par un AbortSignal. Une régression doit être une courbe, pas un incident.
En pratique : réservez WeakRef aux cas où la propriété de l'objet vit réellement ailleurs, appariez chaque emitter.on() à un emitter.off() sur l'événement close de la requête, annulez chaque timer dans un chemin d'arrêt ou d'annulation, et gardez la tendance process.memoryUsage() sur un dashboard avec une alerte sur le plancher post-GC. Cette hygiène de supervision fait partie d'une TMA Node.js sérieuse : une fuite attrapée en tendance coûte une après-midi, une fuite attrapée en incident coûte un week-end.
Checklist fuite mémoire en production
- Suivre les cinq champs de
process.memoryUsage(); juger le plancher post-GC, pas les pics. - Classifier d'abord : heap ou externe, corrélé au temps ou au trafic.
- Snapshot à T puis T+N sous charge stable, sur une instance sortie de rotation.
- Comparer dans DevTools, trier par retained size, suivre le panneau Retainers jusqu'à votre code.
- Corriger, déployer, re-mesurer ; ne jamais livrer
--max-old-space-sizecomme correctif. - Borner les caches, apparier chaque
on()à unoff(), annuler les timers, auditer la portée module en serverless.