Fuite de mémoire Node.js : trouver les gestionnaires, caches et timers en fuite
Repérez une fuite mémoire Node.js en identifiant listeners, caches et timers en fuite, puis prouvez la correction avec des heap snapshots et des tests reproductibles.

À quoi ressemble une fuite mémoire dans une app Node.js
Une fuite mémoire dans Node.js, c'est quand votre application conserve de la mémoire dont elle n'a plus besoin. L'élément clé est l'augmentation de la mémoire dans le temps qui ne redescend jamais complètement, même après que le pic de travail est terminé.
En production, cela apparaît souvent comme une montée lente : tout va bien pendant des minutes ou des heures, puis les réponses ralentissent, le processus lance le GC plus souvent, et finalement l'app redémarre ou plante avec une erreur out-of-memory. Si vous regardez les métriques de base, vous pouvez voir RSS et heap monter sur plusieurs déploiements, cycles de trafic ou jobs en arrière-plan.
Signes courants :
- La mémoire augmente après chaque requête ou exécution de job
- Les pauses GC deviennent plus longues et plus fréquentes
- Les redémarrages deviennent prévisibles (par exemple, chaque nuit après un batch)
- Les conteneurs atteignent les limites mémoire même à trafic normal
- L'app ralentit sans pic CPU apparent
Tout ce qui monte n'est pas forcément une fuite. Une certaine croissance est normale : un cache qui se réchauffe, une compilation unique, ou un pic de trafic qui redescend. Certains « leaks » sont en fait des caches intentionnels trop gros ou sans limite. La différence tient à la stabilisation : une app saine monte puis se stabilise autour d'un palier. Une app qui fuit fixe de nouveaux paliers en continu.
Un exemple simple : un prototype ajoute un gestionnaire d'événements à chaque requête sans jamais le supprimer. Chaque requête « se termine », mais le listener conserve une référence à des données de cette requête. La mémoire monte par petits pas jusqu'à devenir un vrai problème.
Pour corriger les fuites sans deviner, il vous faut deux choses : un moyen reproductible de provoquer la croissance, et une façon de prouver que la correction a marché. La preuve ressemble généralement à la mémoire qui se stabilise et aux heap snapshots montrant que les objets qui croissaient ne croissent plus.
Triage rapide : confirmez que vous poursuivez le bon problème
Avant de chasser une fuite Node.js, assurez-vous que l'app fuit vraiment (la mémoire monte et reste haute), et n'est pas seulement en train de gérer un pic temporaire (la mémoire monte puis redescend après GC).
Commencez par regarder quelques chiffres ensemble. Une seule métrique peut vous induire en erreur, surtout dans du code prototype où des « quick fixes » cachent souvent la cause réelle.
- Process memory (RSS) : mémoire totale que l'OS attribue au processus.
- Heap used : objets JavaScript gérés par V8.
- Latence de la boucle d'événements : si elle augmente, l'app est bloquée par du travail ou le GC est thrashé.
- Latence des requêtes : les fuites apparaissent souvent par des réponses plus lentes dans le temps.
- Taux d'erreurs / timeouts : une « fuite » peut parfois être des tempêtes de retries ou des jobs bloqués.
Ensuite, distinguez rapidement la croissance du heap et la croissance native. Si heap used continue d'augmenter sur plusieurs minutes sous charge stable, vous retenez probablement des objets JS (listeners, caches, closures, tableaux, Maps). Si RSS augmente mais heap used reste stable, suspectez de la mémoire hors heap JS : gros Buffers, streams non fermés, add-ons natifs, ou une librairie de logs/metrics qui accumule des données.
Les fuites de prototype vers production viennent souvent des mêmes habitudes : singletons globaux qui accumulent l'état, caches sans limite, listeners ajoutés par requête et timers démarrés sans chemin d'arrêt. On retrouve ces patterns dans du code généré ou fortement modifié par des outils IA, où le code « marche une fois » mais manque de nettoyage.
Fixez un objectif simple avant de toucher le code : reproduire la croissance en minutes, pas en jours. Choisissez une route ou un job qui déclenche le problème, appliquez une charge stable, et définissez le « succès » ainsi : après le début du test, la mémoire augmente de manière répétable (par exemple +20 Mo toutes les 2 minutes). Une fois que vous avez ça, vous pouvez prouver la correction plus tard.
Si vous avez hérité d'un prototype chaotique et ne pouvez même pas obtenir des mesures stables, c'est précisément le moment où un audit rapide (comme ce que fait FixMyMess) peut repérer les plus gros risques de rétention avant de réécrire la moitié de l'app.
Rendre la fuite reproductible avant de toucher le code
Une fuite Node.js est difficile à corriger si elle n'apparaît que « parfois ». Avant de modifier quoi que ce soit, transformez le problème en une action reproductible qui fait monter la mémoire à la demande.
Commencez par choisir un déclencheur qui correspond à l'utilisation réelle. Bons candidats : une route HTTP unique, le tick d'un job en arrière-plan, un cycle de connexion/déconnexion WebSocket, ou une tâche d'import. Choisissez l'action la plus petite qui provoque encore la montée.
Répétez ensuite ce déclencheur en boucle serrée. Vous pouvez le faire manuellement (rafraîchir la même page 50 fois), mais un petit script est préférable car il élimine la variation humaine. L'objectif n'est pas le test de charge mais la constance.
Gardez les variables stables pendant la reproduction :
- Utilisez le même compte utilisateur et les mêmes permissions à chaque exécution
- Utilisez la même taille d'entrée (même payload, même nombre d'enregistrements)
- Utilisez le même environnement (local ou staging, pas mixte)
- Redémarrez l'app entre les expériences pour partir d'une baseline propre
- Coupez le trafic non lié pour que le bruit de fond ne masque pas le pattern
Définissez maintenant une métrique claire de réussite/échec. Une simple : après la boucle, forcez un garbage collection pendant le test ; le heap devrait revenir proche de la baseline. S'il continue d'augmenter run après run, vous avez un repro fiable.
Exemple concret : la mémoire monte quand les utilisateurs ouvrent un écran « mises à jour en direct ». Faites une boucle qui connecte au WebSocket, attend 3 secondes, puis se déconnecte, et répétez 200 fois. Si le heap croit à chaque cycle de connexion, vous avez restreint la fuite aux listeners, timers ou caches par connexion.
C'est aussi là que les apps prototypes se cassent souvent. Si votre code a été généré par Replit, v0, ou Cursor et qu'il est difficile de rendre la fuite reproductible, FixMyMess peut faire un audit rapide pour isoler l'action qui déclenche fiablement la croissance avant que vous passiez des heures à deviner.
Heap snapshots : l'outil qui rend les fuites visibles
Un heap snapshot est une photo des objets que votre processus Node.js retient en mémoire à un instant donné. Il montre combien d'objets existent, leur taille, et ce qui les maintient vivants. Quand vous traquez une fuite Node.js, c'est souvent le moyen le plus rapide d'arrêter de deviner.
Ce que ça vous dit : quels types d'objets montent (tableaux, Maps, chaînes, closures), et les chemins qui les gardent accessibles. Ce que ça ne vous dit pas : la ligne exacte de code qui a créé l'objet, ou si un pic ponctuel est « mauvais » en soi. Vous avez toujours besoin d'un test reproductible et d'un peu de travail de détective.
Un concept clé dans les snapshots : les retainers. Un objet n'est pas garbage-collected si quelque chose le référence encore. Les retainers sont la chaîne de références qui maintient un objet vivant, par exemple : un singleton global contient une Map, la Map contient des payloads de requête, et ces payloads incluent de grosses chaînes. La « fuite » est souvent moins l'objet qui grandit que le retainer qui aurait dû être vidé.
Prévoyez de prendre trois snapshots pour comparer ce qui augmente dans le temps :
- Baseline : juste après le démarrage, avant toute charge.
- Snapshot 2 : après une quantité connue de charge (par ex. 200 requêtes).
- Snapshot 3 : après encore plus de la même charge (par ex. 600 requêtes).
Si les mêmes groupes d'objets augmentent entre snapshot 2 et snapshot 3, vous avez un signal fort. Si la mémoire monte mais le nombre d'objets reste stable, vous regardez peut-être des buffers, des add-ons natifs ou du caching normal.
La confidentialité est importante. Les snapshots peuvent contenir des données utilisateur, tokens, cookies, payloads de requête et même des secrets exposés que des prototypes parfois loguent ou stockent en mémoire. Traitez les snapshots comme des données de production : enregistrez-les avec soin, partagez-les peu, et supprimez-les quand vous avez fini.
Étape par étape : capturer et comparer des snapshots pour trouver ce qui croît
Quand vous suspectez une fuite Node.js, les heap snapshots sont le moyen le plus rapide d'arrêter de deviner. L'astuce est de prendre des snapshots autour d'une action répétée pour voir ce qui augmente à chaque itération.
1) Capturer trois snapshots autour de la même action
Démarrez votre app d'une manière qui permet de prendre des heap snapshots (par exemple via votre debugger ou inspector). Puis répétez l'action utilisateur que vous pensez être la cause de la fuite (une requête, un chargement de page, l'exécution d'un job en arrière-plan).
Utilisez ce rythme simple :
- Snapshot A : prise baseline juste après que l'app soit « stabilisée »
- Faites la même action N fois (commencez par 20–50)
- Snapshot B : deuxième snapshot immédiatement après
- Faites la même action N fois à nouveau
- Snapshot C : troisième snapshot
Si B est plus grand que A, et C est plus grand que B d'un montant similaire, cette augmentation régulière par itération est un fort signal de fuite.
2) Comparer les snapshots et suivre le chemin de rétention
Ouvrez la vue de comparaison entre A et B (puis B et C). Concentrez-vous sur les types d'objets qui augmentent, pas sur les pics ponctuels.
Cherchez :
- Noms de constructeurs qui montent (par ex. Array, Map, Listener, Timeout, Buffer)
- Collections qui grossissent (entrées Map, éléments Set, objets mis en cache)
- Objets détachés ou « unreachable » qui restent pourtant retenus
- Le chemin de rétention (ce qui maintient l'objet en mémoire)
Le chemin de rétention est la partie payante. Il pointe souvent vers un singleton global, un cache au niveau module, un EventEmitter, ou une liste de timers.
3) Notez ce que vous voyez avant de modifier le code
Pendant l'inspection, prenez des notes rapides pour ne pas perdre le fil :
- Les 2–3 noms de constructeurs qui augmentent le plus
- La racine de rétention (global, export de module, closure du handler de requête)
- Tout indice de fichier ou module montré dans le snapshot
- Taux de croissance approximatif (par ex. +500 objets par 50 requêtes)
Cette liste concise rend la correction ciblée. C'est aussi la même preuve que des équipes comme FixMyMess utilisent dans un audit gratuit pour dire si la fuite vient de listeners, caches ou timers avant de toucher au code.
Listeners d'événements en fuite : la fuite prototype la plus courante
Un classique dans le code prototype : un listener est ajouté encore et encore sans jamais être retiré. La mémoire augmente lentement, puis le processus commence à pauser, à timeouter, ou à planter.
Cela arrive souvent quand une fonction de « setup » s'exécute à chaque requête, reconnexion ou job, et fait par exemple emitter.on(...) sans vérifier si elle est déjà abonnée. Chaque nouveau listener peut garder des données supplémentaires vivantes, surtout si le handler ferme sur des objets de requête, des données utilisateur ou de gros buffers.
Endroits courants où ça apparaît :
- Instances d'
EventEmitterutilisées comme bus global - Connexions WebSocket qui se reconnectent et se réabonnent
- Streams HTTP où les handlers
dataeterrors'accumulent - Clients de base de données qui attachent des listeners à chaque requête
- Événements
processcommeuncaughtExceptionouSIGTERMenregistrés plusieurs fois
Les heap snapshots peuvent révéler ce pattern. Cherchez un nombre croissant de fonctions sous des tableaux de listeners (souvent sur un emitter), ou de nombreuses closures similaires qui référencent les mêmes variables externes. Un indice fort est de voir des objets retenus ressemblant à des données de requête/réponse attachés au contexte d'une fonction listener ou à sa closure.
Exemple concret : une route Express appelle subscribeToUpdates(userId) à chaque requête, et cette fonction ajoute ws.on('message', ...). Si elle ne se désabonne jamais quand la requête se termine (ou quand l'utilisateur se déconnecte), le WebSocket garde des références aux anciens handlers et à leurs données capturées.
Les corrections sont souvent ennuyeuses mais efficaces :
- Utiliser
oncepour les événements qui doivent se produire une seule fois - Appeler
off/removeListenerlors du nettoyage (disconnect, fin de requête, fin de job) - Éviter les subscriptions par requête à des emitters globaux ; router les événements via un objet scoped
- Conserver la fonction handler pour pouvoir la supprimer avec la même référence plus tard
- Ajouter des garde-fous : logger
listenerCount, et traiter les avertissements comme de véritables bugs
Si vous avez hérité d'un code généré par IA avec ces patterns, FixMyMess commence souvent par cartographier les cycles de vie des listeners et supprimer les pièges « subscribe on every call » cachés avant toute autre chose.
Caches qui ne font que croître : Maps, mémoïsation et singletons globaux
Beaucoup de « fuites mémoire » dans des prototypes ne sont pas des bugs mystérieux. Ce sont des caches qui ne lâchent rien. Dans une chasse à la fuite Node.js, c'est un des premiers endroits à inspecter car cela ressemble souvent à « l'app marche bien » jusqu'à ce que le trafic ou le temps rende le cache énorme.
Le pattern classique : une Map ou un objet utilisé comme lookup rapide, mais sans limite de taille ni expiry. Si la clé est construite à partir d'input utilisateur (termes de recherche, URLs, headers, IDs utilisateur, prompts), le nombre de clés uniques peut croître indéfiniment.
Où chercher
Commencez par trouver tout ce qui stocke des données entre les requêtes : variables au niveau module, singletons, ou fichiers « utilitaires » qui exportent un cache.
Quelques coupables fréquents :
- Une Map de dé-duplication de requêtes (
inFlightRequests.get(key)) qui n'efface jamais sur les chemins d'erreur - Mémoïsation autour de fonctions coûteuses, clé par l'input brut
- Une Map globale des « dernières réponses » pour debugging ou analytics
- Caches qui stockent des objets de réponse entiers, des lignes DB ou des Buffers
- Données de type session gardées en mémoire au lieu d'un vrai store
Scénario qui fuit vite : vous mettez en cache les résultats de GET /search?q=... indexés par la query entière. Une semaine plus tard, vous avez des centaines de milliers de queries uniques et chaque valeur inclut un gros payload JSON. Les heap snapshots montreront souvent une grosse Map retenant tableaux, chaînes et objets imbriqués.
Patterns de cache plus sûrs
La correction consiste généralement à faire du cache un vrai cache, pas une archive :
- Ajouter une taille max (évincer les entrées LRU ou les plus anciennes)
- Ajouter un TTL et nettoyer sur un intervalle qui peut être arrêté
- Normaliser les clés (lowercase, trim, trier les params) pour réduire l'explosion de clés
- Stocker des IDs ou de petits résumés, pas des objets entiers ou des réponses brutes
- Toujours supprimer les entrées sur les chemins d'erreur ou timeout
Dans les prototypes générés par IA, ces caches sont souvent éparpillés dans des fichiers utilitaires et des singletons. FixMyMess trouve fréquemment 2–3 Maps qui grossissent indépendamment dans le même codebase, chacune retenant plus que nécessaire.
Intervalles et boucles d'arrière-plan qui ne s'arrêtent jamais
Les timers sont une source facile de fuite Node.js, surtout dans des apps démarrées comme prototypes. L'erreur classique est de créer un setInterval() (ou un setTimeout() chaîné) à l'intérieur d'un handler de requête, puis de ne jamais l'effacer. Chaque requête ajoute une autre boucle de fond qui garde des références vivantes.
Cela survient souvent avec des fonctionnalités « rapides » : polling d'une API tierce, retries de jobs échoués, vérification d'une queue, ou rafraîchissement d'un cache. Quand ce code est dans une route ou un setup par utilisateur, le timer ferme sur des données de requête (user id, token d'auth, payload), et cette closure reste en mémoire tant que le timer existe.
Exemple réaliste : une route Express /start-sync crée un intervalle pour polling toutes les 2 secondes. Si l'utilisateur rafraîchit la page ou appelle la route deux fois, vous avez deux intervals pour le même utilisateur. Multipliez par le trafic réel et la mémoire augmente progressivement.
Les heap snapshots donnent souvent de bons indices. Vous verrez fréquemment des comptes croissants d'objets liés aux timers, ainsi que des closures retenues qui pointent vers des objets de requête ou de session. Si la comparaison montre plus d'objets « listeners » et Timeout après chaque run, la liste des timers grossit.
Patterns de correction qui marchent :
- Créer des timers programmés une seule fois au démarrage, pas à l'intérieur des routes
- Stocker les IDs de timer et toujours appeler
clearInterval()ouclearTimeout()quand le job finit - Lier la durée de vie du timer à la connexion : annuler au disconnect, logout ou fermeture du WebSocket
- Protéger contre les doublons (par ex. un intervalle par utilisateur ou par workspace)
- Préférer une boucle worker unique qui récupère les jobs d'une queue plutôt qu'un timer par requête
Après la modification, relancez la même charge et prenez de nouveaux heap snapshots. Si la correction est réelle, les objets liés aux timers cessent de croître et la mémoire commence à se stabiliser.
Si votre app a été générée par Lovable, Bolt ou Replit et que des timers sont dispersés dans les routes, FixMyMess peut effectuer un audit rapide et indiquer exactement où les boucles sont créées et pourquoi elles ne sont jamais arrêtées.
Prouver la correction : relancer le test et confirmer que la mémoire se stabilise
Une vraie correction change ce que l'app retient en mémoire. Un pansement ne change que ce que vous observez. Redémarrer le serveur, augmenter la mémoire du conteneur ou forcer le GC peut améliorer les graphiques temporairement, mais la même fuite est toujours là.
Traitez l'étape de preuve comme une expérience de laboratoire. Utilisez les mêmes étapes de reproduction, la même taille de données et les mêmes settings runtime que lorsque vous avez d'abord vu la fuite Node.js.
Capturez un nouvel ensemble de heap snapshots : un au démarrage propre, un après que la fuite ait eu le temps de croître, et (si votre test inclut un cooldown) un après l'arrêt de la charge. Puis comparez-les à vos snapshots « avant ».
Vous avez terminé quand deux choses sont vraies :
- Les types d'objets qui croissaient (par ex. tableaux de listeners, entrées Map, réponses mises en cache, closures de timers) cessent d'augmenter entre snapshots.
- Après la fin de la charge, le heap monte puis descend et se stabilise près d'une plage constante au lieu d'augmenter à chaque exécution.
Exemple simple : vous avez supprimé un setInterval créé par requête. Au prochain run, le snapshot deux ne devrait plus montrer des milliers de callbacks d'interval identiques retenant des données de requête, et le snapshot trois ne devrait pas être significativement plus haut que le snapshot deux.
Si la fuite est « corrigée » mais que le heap monte encore, vérifiez que vous n'avez pas simplement déplacé la croissance ailleurs. Masques courants : ajouter un cache LRU sans taille limite, ou enlever des listeners sur un chemin mais pas sur les chemins d'erreur.
Pour la sécurité à long terme, ajoutez un petit test de régression avant la mise en production. Gardez-le court et ennuyeux, juste assez pour attraper le retour du même pattern :
- Lancez une petite charge pendant 2 à 5 minutes sur une build staging
- Enregistrez le pic RSS/heap et échouez si ça dépasse un seuil raisonnable
- Optionnel : sauvegardez un heap snapshot comme artifact et comparez le nombre d'objets retenus
Si vous avez hérité d'une app prototype générée par IA et que la même fuite revient après des patches rapides, FixMyMess lance souvent ce cycle relancer-et-comparer après les réparations pour vérifier que le changement est réel et pas seulement espéré.
Erreurs courantes qui font perdre des heures
Un snapshot n'est pas une preuve. Une vue unique peut montrer beaucoup d'objets, mais elle ne vous dit pas ce qui croît. Vous avez besoin d'au moins deux snapshots pris au même point de votre test pour comparer et voir quels constructeurs et retainers augmentent.
Le bruit est un autre tueur de temps. Si vous soumettez l'app à des patterns de trafic mélangés (login, uploads, cron tasks, pages aléatoires) vous verrez une croissance difficile à expliquer. Gardez une boucle reproductible qui déclenche la fuite suspectée, puis ne changez qu'une chose à la fois.
Il est aussi facile d'accuser le garbage collector. Le GC Node.js peut sembler « paresseux » sous charge, mais si la mémoire monte et ne redescend jamais, quelque chose est encore fortement référencé. Les coupables habituels : Maps globales, tableaux dans des modules, closures capturant de gros objets, et listeners ajoutés à chaque requête sans suppression. Quand vous chassez une fuite Node.js, concentrez-vous sur ce qui garde des références, pas sur les réglages du GC.
Un autre piège : soigner le symptôme au lieu de la cause. Vider un cache « quand il devient gros » peut masquer le problème pendant une semaine, puis il revient en production.
Que faire à la place
Visez des corrections qui rendent la croissance impossible :
- Ajoutez des limites de taille et éviction (TTL ou LRU) aux caches en mémoire
- Assurez-vous que les listeners sont enregistrés une seule fois, ou supprimés lors du nettoyage
- Arrêtez timers et intervals quand un job se termine ou qu'une socket se ferme
- Évitez de stocker des objets de requête, sessions ou grosses réponses dans des globals
Exemple : un prototype crée setInterval par session utilisateur pour « rafraîchir des données », mais ne l'efface jamais au logout. Le heap semble aléatoire jusqu'à ce que vous lanciez une boucle login/logout et compariez les snapshots. Alors une callback timer retenue apparaît comme racine.
Si vous avez hérité d'une app Node générée par IA et que la même fuite réapparaît après des correctifs rapides, FixMyMess commence typiquement par un audit court pour trouver le chemin de rétention exact, puis applique un nettoyage réel et des limites pour que la correction tienne.
Checklist rapide et étapes suivantes
Une fuite Node.js est facile à discuter et difficile à prouver. Utilisez cette checklist rapide pour rester concentré et vous assurer de pouvoir montrer que la fuite a disparu, pas seulement que c'est « mieux sur votre machine ».
Checklist rapide
- Pouvez-vous reproduire la croissance mémoire en moins de 10 minutes avec un test reproductible (mêmes endpoints, mêmes payloads, même concurrence) ?
- Avez-vous pris au moins 3 heap snapshots (baseline, en milieu de run, près de l'échec) et comparé ce qui croît entre eux ?
- Avez-vous inspecté spécifiquement les suspects habituels : listeners d'événements, caches en mémoire (Maps, tableaux, mémoïsation), et timers/intervals ?
- Après les changements de code, avez-vous relancé exactement le même test et confirmé que la mémoire se stabilise (et que les cycles GC ne montent pas sans fin) ?
- Avez-vous aussi vérifié les « signaux secondaires » qui pointent vers la cause, comme le listenerCount, les handles ouverts, et le nombre de clés croissant dans les Maps ?
Si un point échoue, faites une pause et corrigez d'abord votre processus. La plupart du temps perdu vient de modifications de code avant d'avoir une repro fiable, ou d'un seul snapshot suivi de suppositions.
Étapes suivantes
Une fois la stabilité prouvée, empêchez le retour du problème :
- Ajoutez un soak test simple à votre routine de release (10–20 minutes suffisent souvent à attraper des régressions)
- Mettez des garde-fous autour du code propice à la croissance : limitez les caches, supprimez les listeners au nettoyage, et arrêtez les intervals quand le travail est fini
- Documentez la « propriété » des boucles d'arrière-plan et des singletons pour qu'ils ne se multiplient pas au fil de l'évolution de l'app
Si votre app a commencé comme un prototype généré par IA, les fuites viennent souvent d'un état global embrouillé, de listeners dupliqués, ou de jobs d'arrière-plan ajoutés pendant des expérimentations et jamais retirés. Dans ces cas, un audit ciblé suivi d'un refactor est généralement plus rapide qu'un patchwork. FixMyMess peut réaliser un audit de code gratuit, identifier ce qui grandit et aider à transformer le prototype en code prêt pour la production avec des corrections vérifiées.