La chasse aux fuites mémoire en JavaScript : atteignabilité, retenteurs et nettoyage
Comprendre pourquoi le JavaScript à collecte de déchets continue de présenter des fuites mémoire, quels schémas courants retiennent la mémoire, et comment identifier la cause à l’aide d’extraits d’heap et de chaînes de retention.
La collecte de déchets libère ce qui est inatteignable, pas ce qui n’est pas utilisé
Puisque JavaScript ne vous demande jamais d’appeler malloc() ou free(), il est tentant de croire que le moteur gère entièrement la mémoire. Cette croyance n’est qu’en partie vraie. Le collecteur ne récupère pas un objet une fois que votre code en a terminé avec lui ; il le récupère uniquement lorsque plus rien ne peut y accéder. Si une référence oubliée pointe encore vers un objet, le moteur ne peut pas savoir que cet objet est inutile. Du point de vue du moteur, tout ce qui est accessible pourrait encore être nécessaire.
Cette différence entre « plus utilisé » et « plus accessible » est à l’origine de certains des bugs de performance les plus difficiles dans le développement web moderne.
Un scénario : le tableau de bord qui ralentissait chaque après-midi
Imaginez un tableau de bord opérationnel que le personnel garde ouvert pendant toute la durée du service. En environnement de test, il fonctionne rapidement : chargement initial instantané, appels API efficaces, scores élevés dans Lighthouse. Puis arrivent les retours en environnement de production. Après cinq ou six heures, le changement de onglet devient lent, les graphiques se redessinent peu à peu, et même un simple modal met un temps considérable à s’ouvrir.
La première réaction typique est de blâmer le backend. L’équipe ajuste les requêtes de base de données, s’assure que les réponses API restent en dessous de 100 millisecondes, et constate une faible consommation de CPU sur les serveurs. Rien de tout cela ne permet d’expliquer un ralentissement qui s’intensifie au fil de la journée.
Cette avancée provient du Gestionnaire de tâches de Chrome. La taille de l’onglet était d’environ 150 MB au début de la matinée et a atteint près de 1,4 GB en fin d’après-midi. Chaque navigation, chaque dialogue, chaque mise à jour de widget laissait un peu plus de mémoire utilisée. Chaque allocation était minime ; mais des milliers d’elles ne l’étaient pas. La rendu, la latence réseau et la vitesse d’exécution étaient toutes normales. L’application ne libérait tout simplement jamais les objets dont elle n’avait plus besoin.
Comment les moteurs décident de ce qui reste actif
Créer un objet en JavaScript ne nécessite absolument aucune procédure particulière :
const user = {
id: 101,
name: "Emma"
};
Lorsqu’aucun élément ne fait plus référence à user, le moteur peut alors le récupérer. Ce qui est intéressant, c’est la manière dont il prend cette décision. V8 (Chrome et Node.js), SpiderMonkey (Firefox) ainsi que JavaScriptCore (Safari) suivent tous un graphe d’objets reliés par des références. Les racines, comme l’objet global, se trouvent en haut de ce graphe, et tout ce que votre application crée dépend d’elles : l’instance de l’application, le routeur, le stockage, les arbres de composants ainsi que toutes les variables globales.
Un cycle de collecte commence à partir de ces racines et suit toutes les références possibles. Tout ce que le cycle atteint survit, tandis que tout ce qu’il ne peut pas atteindre devient éligible à la suppression. Le mot clé est éligible, et la propriété clé est inaccessible. Un objet n’est pas collecté parce qu’il est ancien, inutilisé ou oublié ; il l’est uniquement parce qu’aucun chemin de référence ne mène jusqu’à lui.
Supposons que vous chargiez une longue liste de fiches :
const employees = fetchEmployees();
Quelque temps plus tard, l’interface utilisateur ne montre plus ces données, mais un autre objet continue de faire référence à elles :
cache.employees = employees;
Même si personne ne lit plus jamais cache.employees, le collecteur ne peut pas l’affirmer. La référence existe, donc tout le tableau reste en mémoire. Le moteur se comporte exactement comme prévu ; la fuite provient du code de l’application.
Penser en termes de références plutôt qu’objets
Les fuites deviennent bien plus faciles à comprendre dès lors que l’on cesse de se concentrer sur les objets pour se concentrer plutôt sur les références qui y font référence. Prenons une fonction qui crée un objet et le renvoie :
function createUser() {
const user = {
name: "Alice"
};
return user;
}
const employee = createUser();
Après son exécution, une seule référence relie la variable à l’objet :
employee
│
▼
{ name: "Alice" }
Si vous effacez ensuite cette référence, l’objet n’a plus de liens entrants et la prochaine collecte peut alors le supprimer :
employee = null;
Une correction à apporter si vous essayez cela vous-même : employee a été déclaré avec const dans l’extrait précédent, ce qui provoque une erreur TypeError lors de sa réaffectation. Déclarez-le avec let lorsque vous prévoyez de supprimer la référence ultérieurement. Le principe reste le même : c’est en supprimant la dernière référence que l’objet devient collectable.
Modifiez maintenant légèrement l’exemple afin que la fonction stocke ce qu’elle crée dans un tableau au niveau du module :
const users = [];
function createUser() {
const user = {
name: "Alice"
}; users.push(user);
}
Lorsque createUser() retourne, chaque objet utilisateur est toujours référencé par le tableau users, et ce dernier est accessible depuis le niveau supérieur :
Window
│
▼
users
│
├── User 1
├── User 2
├── User 3
└── User 4
Tant que users est accessible, tous ses éléments le sont également. C’est pourquoi les fuites de mémoire augmentent généralement progressivement : aucun objet isolé n’est volumineux, mais des milliers d’objets petits s’accumulent au fil des heures ou des jours.
Les fuites de mémoire s’accumulent une interaction à la fois
L’expression « fuite de mémoire » fait généralement penser à un objet énorme qui consomme des centaines de mégaoctets. En pratique, le phénomène est presque toujours lié à de petits objets répétitifs.
Imaginez que fermer une boîte de dialogue des paramètres laisse derrière lui environ 20 KB. Cela semble négligeable en soi. Un utilisateur avancé qui ouvre et ferme cette boîte de dialogue 500 fois par jour de travail a déjà perdu environ 10 MB. Ajoutez cinq composants avec des fuites similaires à chaque interaction, une session de huit heures, plusieurs onglets ouverts en même temps et des mises à jour en direct arrivant toutes les quelques secondes, et ces quantités négligeables se transforment en centaines de mégaoctets.
Ces calculs expliquent également pourquoi les développeurs s’en rendent rarement compte. Pendant le développement, on recharge la page toutes les quelques minutes, ce qui efface complètement ces pertes. Les utilisateurs, eux, ne rechargent pas ; ils continuent à travailler.
Pourquoi les applications single-page en souffrent le plus
Les sites classiques à plusieurs pages disposaient d’une sécurité involontaire : chaque navigation chargeait un document frais et éliminait tout l’espace mémoire JavaScript, y compris les objets fuyants.
Les applications monopage développées avec React, Angular, Vue, Svelte ou des frameworks similaires peuvent fonctionner pendant des heures sans rechargement complet. Cela est excellent pour l’expérience utilisateur et c’est précisément pourquoi la discipline en matière de mémoire est importante. Chaque changement de route, chaque modal, chaque notification, chaque message WebSocket et chaque mise à jour de graphique crée des objets. S’ils ne sont pas libérés correctement, ils restent en mémoire aussi longtemps que la page.
Il y a une ironie ici : plus l’expérience est bonne, plus les utilisateurs restent longtemps, et plus il y a de chances que de petites fuites mémoire s’accumulent.
Le collecteur est sophistiqué, pas clairvoyant
Les moteurs modernes utilisent un collectage incrémental et génératif, une marquage concurrentiel, une compactation ainsi qu’un collectage pendant les périodes d’inactivité. Ces techniques rendent la gestion de la mémoire rapide et discrète, mais elles ne peuvent pas corriger les erreurs logiques.
Imaginez que vous prêtez un livre à quelqu’un sans jamais demander son retour. Cette personne ne peut pas savoir que vous l’avez oublié, donc pour elle, vous souhaitez toujours le récupérer un jour. Les références fonctionnent de la même manière. Tant que votre application conserve une référence, le moteur suppose que l’objet est important, que votre code y accède à nouveau ou non. Il ne peut pas lire les intentions ; il suit simplement les liens dans le graphe.
Ce changement d’approche modifie la façon dont vous déboguez. Au lieu de vous demander pourquoi le collecteur ne libère pas la mémoire, vous posez une question plus utile : qu’est-ce qui conserve encore une référence à cet objet ? C’est presque toujours là que se trouve la faute.
Les schémas à l’origine de la plupart des fuites dans le monde réel
Le collecteur n’est pas défectueux ; les fuites se produisent parce que le code conserve des références qu’il aurait dû supprimer. Ces références semblent rarement suspectes. Elles proviennent de code ordinaire et apparemment normal, et non d’algorithmes exotiques ou de bugs de navigateur. Les modèles suivants couvrent les fuites que vous rencontrerez le plus souvent en environnement de production.
Écouteurs d’événements qui ne sont jamais supprimés
Les écouteurs d’événements figurent parmi les causes les plus fréquentes, en particulier dans les SPAs. Leur mise en place est généralement innocente : on prend un élément et on y associe un gestionnaire d’événement.
const button = document.getElementById("save");
button.addEventListener("click", saveDocument);
Plus tard, l’utilisateur quitte la page et le bouton disparaît. Supprimer un élément du DOM ne suffit pas à rompre toutes les références JavaScript associées. Si le gestionnaire d’événements est toujours enregistré et que quelque chose d’autre maintient l’accès à l’élément ou au gestionnaire, tant l’écouteur que ce sur quoi il fait référence restent en mémoire. La fuite est particulièrement grave lorsque des écouteurs sont attachés à des cibles pérennes comme window ou document, car ces cibles ne disparaissent jamais. La solution consiste à désenregistrer explicitement le gestionnaire d’événements :
button.removeEventListener("click", saveDocument);
Dans React, Angular ou Vue, faites cela pendant la phase de démontage ou de destruction du cycle de vie du composant. Une bonne pratique consiste à considérer chaque appel à addEventListener() comme un engagement : lorsque vous en ajoutez un, déterminez quand et où il sera supprimé. Fournir un signal AbortController à plusieurs écouteurs et l’annuler une fois lors du démontage est un moyen pratique de tenir cet engagement en masse.
Timers qui survivent à leur écran
Les timers causent également des fuites de manière discrète. Un tableau de bord qui récupère de nouvelles données toutes les cinq secondes pourrait ressembler à ceci :
const timer = setInterval(() => {
loadLatestData();
}, 5000);
Si l’utilisateur quitte la page et que l’intervalle n’est jamais effacé, la fonction de rappel continue d’être exécutée en arrière-plan. Elle maintient sa fermeture vivante, ainsi que toutes les fonctions, variables ou même instances de composants auxquelles cette fermeture fait référence. Quelques intervalles oubliés peuvent consommer bien plus de mémoire que ce à quoi on s’attendrait. Effacez-les lorsque leur propriétaire s’éloigne :
clearInterval(timer);
La même discipline s’applique à setTimeout() (via clearTimeout()) et à requestAnimationFrame() (via cancelAnimationFrame()).
Nœuds DOM détachés
const modal = document.getElementById("modal");
modal.remove();
Il semble avoir disparu, mais si une variable, un tableau, une fermeture ou un objet d’état pointe encore vers cet élément, celui-ci ne peut pas être collecté, tout comme ses enfants. Les applications qui créent dynamiquement des modaux, des tooltips, des listes déroulantes ou des panneaux de notification sont particulièrement vulnérables à ce problème. Chaque nœud est petit, mais après des centaines d’interactions, les sous-arbres détachés peuvent occuper une quantité surprenante de mémoire.
Fermetures qui captent plus que nécessaire
Les fermetures sont l’une des fonctionnalités les plus puissantes de JavaScript, mais elles facilitent également le gaspillage inutile de mémoire. Prenons l’exemple d’une fonction usine qui alloue un grand tableau avant de renvoyer une fonction :
function createLogger() {
const largeData = new Array(100000).fill("data");
return function () {
console.log("Logging...");
};
}
La fonction retournée ne touche jamais largeData. Le fait que cet array reste en mémoire dépend de la manière dont le moteur représente le contexte contenant. En pratique, V8 ne conserve que les variables que des closures présents dans ce contexte référencent réellement, de sorte que cet extrait précis ne garde généralement pas l’array. Le risque apparaît lorsque un second closure créé dans le même contexte utilise effectivement largeData : les closures partagent un objet de contexte, ce qui fait que le logger à longue durée de vie finit également par conserver l’array volumineux. L’utilisation de eval à l’intérieur du contexte force également le moteur à conserver tout ce qui y est présent.
Rien de tout cela ne rend les closures mauvais ; le JavaScript moderne en dépend. La leçon à retenir est d’être prudent quant aux éléments que peut voir une fonction à longue durée de vie. Si elle n’a besoin que d’une seule valeur, transmettez ou copiez cette valeur plutôt que de s’appuyer sur un objet ou un ensemble de données entier. De petits ajustements au champ d’application peuvent réduire considérablement l’utilisation de la mémoire.
Cachets sans politique d’éviction
Le caching permet d’éviter de travailler à plusieurs reprises, mais un cache qui ne cesse de grandir n’est qu’une fuite lente issue de bonnes intentions. Voici un exemple minimal de recherche mémorisée :
const cache = {};
function getUser(id) {
if (!cache[id]) {
cache[id] = fetchUser(id);
} return cache[id];
}
Il fonctionne bien au début. Six mois après son déploiement, il peut contenir des centaines de milliers d’entrées que personne ne demandera à nouveau. Au lieu de laisser un cache croître indéfiniment, envisagez :
- Une taille maximale
- Une expiration basée sur le temps pour les entrées obsolètes
- Une stratégie d’éviction LRU (moins utilisé récemment)
WeakMap lorsque le cache est indexé par des objets dont la durée de vie doit déterminer celle de l’entréeNotez que WeakMap n’accepte que des objets (ou des symboles non enregistrés) comme clés, il ne remplace donc pas un mécanisme LRU pour des caches indexés par des IDs numériques comme celui mentionné ci-dessus. Si vous souhaitez vous rafraîchir la mémoire sur le comportement des références faibles, consultez notre aperçu de Symbols, WeakMaps, Proxies et generators. Un cache sans stratégie de suppression n’est pas vraiment un cache ; c’est plutôt un stockage permanent.
Globaux qui durent éternellement
Tout ce qui est attaché au scope global existe aussi longtemps que l’application. Cela est à la fois pratique et risqué. Une collection au niveau de module comme celle-ci :
let allUsers = [];
continue de croître chaque fois que de nouvelles données y sont ajoutées :
allUsers.push(...newUsers);
Sauf si du code le traite explicitement, le tableau ne cesse de s’agrandir. Au fil des mois de développement, les grands objets globaux ont tendance à devenir des lieux d’accumulation des données. Lorsque vous investigatez un problème de performance, l’état global est l’un des premiers endroits à vérifier.
WebSockets et autres connexions à long terme
Les fonctionnalités en temps réel reposent souvent sur WebSockets, et leur ouverture ne nécessite qu’une seule ligne de code :
const socket = new WebSocket(url);
La faute réside dans l’oubli de les fermer. Un socket ouvert continue de recevoir des messages, d’exécuter des callbacks et de conserver l’état de l’application même après que l’utilisateur soit parti ailleurs. Fermez les connexions lorsque la fonctionnalité qui les utilise n’est plus active :
socket.close();
La même règle s’applique aux observables, aux flux, aux émetteurs d’événements personnalisés et à toute autre abonnement qui peut survivre à son consommateur.
Le fil conducteur
Ces exemples semblent différents en apparence, mais ils partagent une même cause racine : quelque chose conserve une référence à un objet qui aurait dû devenir inatteignable. Ce quelque chose est généralement l’un des suivants :
- Un écouteur enregistré
- Une période d’intervalle ou un délai d’attente en attente
- Un contexte de fermeture
- Un cache en croissance continue
- Une variable au niveau du module ou globale
- Un socket ouvert ou une autre abonnement
Lorsque vous pensez en termes de références plutôt qu’objets, la recherche des fuites devient beaucoup plus intuitive. Au lieu de vous demander pourquoi la mémoire continue d’augmenter, demandez-vous ce qui retient encore l’objet. Cette question mène généralement directement à la fuite.
Détecter la fuite avant vos utilisateurs
Connaître les causes est utile, mais dans une base de code réelle, la vraie question est simplement d’identifier où se trouve la fuite mémoire. Une application volumineuse peut compter des milliers de composants et des centaines d’écouteurs, avec des objets créés chaque seconde. Deviner où se situe le problème ne fonctionne que rarement. Les outils de navigateur sont excellents ; ce qui fait la différence, c’est de les utiliser de manière cohérente et systématique, plutôt que d’essayer d’apprendre toutes les fonctionnalités des DevTools.
Vérifier que vous avez bien une fuite mémoire
Une augmentation de la mémoire utilisée n’est pas automatiquement le signe d’une fuite. Les moteurs allouent de la mémoire au fur et à mesure que l’application fonctionne et la libèrent lors des opérations de collecte, de sorte qu’une application en bon état présente un schéma en dents de scie :
Memory
^
| /\ /\ /\
| / \ / \ / \
|______/____\__/____\___/____\____ Time
L’utilisation de la mémoire augmente pendant l’activité de l’application et diminue après chaque opération de collecte. Une application présentant une fuite mémoire montre un comportement différent :
Memory
^
| /\ /\
| / \ / \
| / \ / \
|_______/______\__/______\________
| /
| /
| /
|___________/________________ Time
Ces petites baisses indiquent que le collecteur de mémoire est en cours d’exécution, mais chaque creux se situe à un niveau plus élevé que le précédent. La mémoire ne revient jamais à son niveau de référence initial, ce qui constitue le premier signe réel que des objets sont conservés. Avant de tirer des conclusions, déclenchez manuellement une collecte (l’icône de poubelle dans les panneaux Mémoire et Performance), car un niveau de référence qui semble élevé uniquement parce que la collecte n’a pas encore eu lieu ne signifie pas forcément une fuite mémoire.
Étape 1 : ouvrir le panneau Mémoire
Chrome propose plusieurs outils de suivi de la mémoire, mais vous n’avez pas besoin d’en utiliser tous en même temps. Ouvrez les DevTools et passez au panneau Mémoire. Selon la version de Chrome que vous utilisez, vous verrez différents types d’analyse tels qu’un aperçu du heap, une instrumentation des allocations sur un chronogramme, ou un échantillonnage des allocations ; les noms et options exacts varient d’une version à l’autre, il convient donc de consulter la documentation actuelle des DevTools si les vôtres diffèrent.
Pour la plupart des investigations, un snapshot du heap constitue le bon point de départ, car il montre ce qui occupe actuellement la mémoire.
Étape 2 : enregistrer une valeur de référence
Au préalable, avant d’utiliser la fonctionnalité suspectée, prenez un snapshot de l’état initial de l’application, c’est-à-dire une image du heap. Ensuite, effectuez répétitivement l’interaction que vous soupçonnez problématique. Par exemple :
- Ouvrez et fermez un modal dix fois
- Naviguez aller-retour entre les pages
- Exécutez un téléchargement de fichier
- Appliquez des filtres à une grande table de données
- Alternez sans cesse entre les onglets du tableau de bord
Lorsque vous avez terminé, prenez un deuxième snapshot. Vous disposez maintenant de deux états à comparer.
Étape 3 : comparer les snapshots
C’est ici que l’enquête commence réellement. Si le nettoyage fonctionne, les objets temporaires créés pendant l’interaction devraient disparaître après leur collecte. Sinon, certains types d’objets continuent d’augmenter. Les suspects typiques sont :
- Éléments DOM séparés
- Tableaux volumineux
- Écouteurs d’événements
- Vos propres classes d’application
- Composants de framework qui auraient dû être détruits
Vous n’avez pas besoin de comprendre chaque élément du heap. Utilisez la vue comparative et cherchez les types d’objets dont le nombre augmente d’une quantité constante à chaque fois que vous répétez la même action. La régularité est généralement le meilleur indice.
Les nœuds DOM séparés sont les preuves les plus faciles à repérer
Les nœuds détachés sont l’un des fuites les plus faciles à repérer. Ouvrez et fermez un modal vingt fois ; après chaque fermeture, ce modal devrait disparaître. Si l’aperçu contient encore vingt éléments modaux, quelque chose les retient. Vous pouvez taper « Detached » dans le filtre de classe de l’aperçu pour les lister rapidement.
Le DOM lui-même est rarement le véritable problème. La cause réelle se trouve généralement ailleurs :
- Un gestionnaire toujours enregistré sur l’élément ou sur une cible à longue durée de vie
- Un intervalle ou un délai dont la fonction de rappel mentionne le nœud
- Une fermeture qui a capturé l’élément
- Un stockage, un champ de composant ou un tableau qui a enregistré un pointeur vers lui
Considérez le nœud détaché comme un symptôme : une preuve qu’une autre référence a empêché le nettoyage.
Suivez la chaîne des références
Lorsque vous trouvez un objet qui ne devrait clairement plus exister, la question suivante est de savoir qui le maintient en vie. La section Retainers d’une capture d’écran du heap répond précisément à cette question. Sélectionnez l’objet et Chrome affiche la chaîne de références qui le relie à une racine du GC. Conceptuellement, cela peut se présenter comme suit :
Window
│
Application
│
UserService
│
cachedUsers
│
User Object
L’enquête devient alors simple. Au lieu de se demander pourquoi un objet utilisateur persiste, vous pouvez voir qu’il est référencé par une collection cachedUsers à l’intérieur de UserService. Localiser l’objet conservé est utile ; identifier ce qui le conserve c’est ce qui permet réellement de corriger la faille.
Observer les compteurs en temps réel avec le Performance Monitor
Les captures d’écran sont idéales pour une analyse détaillée, mais ce ne sont pas les seuls outils disponibles. Le Performance Monitor de Chrome affiche des métriques en temps réel qui incluent :
- Taille de la pile JS
- Nombre de nœuds DOM
- Nombre d’écouteurs d’événements JS
- Documents et fenêtres
Si le nombre de nœuds DOM ou d’écouteurs continue d’augmenter lorsque vous répétez la même action, le nettoyage n’a pas eu lieu. L’avantage réside dans la vitesse : vous n’avez pas besoin d’attendre que l’application ralentisse, et des tendances suspectes deviennent souvent visibles en quelques minutes à peine.
Isolez un scénario petit et reproductible
Une erreur fréquente consiste à essayer d’analyser toute l’application en même temps. Préférez plutôt vous concentrer sur une seule interaction :
- Affichez un seul modal, fermez-le, et répétez l’opération vingt fois de suite
- Alternativement, naviguez cinquante fois entre les mêmes deux routes
Les scénarios complexes comme ceux-ci sont bien plus faciles à quantifier. Lorsqu’une action répétée entraîne une augmentation à chaque itération, on a déjà considérablement réduit le champ des recherches, et le code en cause est généralement facile à trouver par la suite.
Testez délibérément des sessions longues
Les développeurs ont tendance à utiliser une application pendant dix ou quinze minutes avant de passer à autre chose. En revanche, les utilisateurs réels d’un tableau de bord interne, d’une plateforme de trading, d’un outil de surveillance ou d’un portail d’assistance peuvent la garder ouverte toute la journée. Intégrez des sessions prolongées dans vos tests de mémoire : laissez l’application ouverte, interagissez avec elle périodiquement et observez comment la mémoire évolue. De nombreux fuites ne deviennent visibles qu’après des centaines ou des milliers d’interactions.
Un flux de travail de débogage qui évite les suppositions
Sauter d’un analyseur à l’autre est une perte de temps. Une séquence bien définie fonctionne généralement mieux :
- Vérifiez que la mémoire continue d’augmenter au fil des collections.
Cela élimine les suppositions dans le processus. Au lieu de supposer qu’un composant particulier est responsable, on laisse les preuves vous y conduire.
Prévenir les fuites avant qu’elles n’affectent la production
Le diagnostic n’est qu’une partie de l’histoire ; la solution la plus économique consiste à éviter les fuites dès le départ. La plupart des fuites ne sont pas le résultat d’une mauvaise compréhension du JavaScript de la part des développeurs. Elles surviennent parce que les applications modernes ont une longue durée de vie, sont très interactives et allouent constamment des ressources, et dans cet environnement il est facile d’oublier que tout ce que l’on crée doit finalement être détruit. Les équipes qui rencontrent rarement des problèmes de mémoire n’écrivent pas nécessairement du code plus intelligent ; elles ont simplement des habitudes qui rendent les fuites peu probables.
Donnez à chaque ressource une date de fin d’utilisation explicite
Chaque fois que le code crée quelque chose de pérenne, posez-vous une question : quand cela sera-t-il détruit ? Cela s’applique bien au-delà de la mémoire brute :
- Les écouteurs sur les éléments,
windowoudocument - Les intervalles, les délais et les frames d’animation
- Les sockets et autres connexions réseau
Leur mise en place est généralement simple ; c’est lors du nettoyage que les applications échouent. Une règle simple le résume : si votre code a un début, il doit aussi avoir une fin. Cette mentalité seule permet d’éviter une part surprenante de fuites mémoire.
Faire en sorte que les composants se nettoient eux-mêmes
Les frameworks basés sur des composants encouragent l’utilisation d’unités autonomes, et ces unités autonomes doivent inclure une fonction de démontage. Un composant qui lance un chronomètre l’arrête lorsqu’il est désinstallé. Un composant qui enregistre des écouteurs les supprime. Rien de ce que le composant a démarré ne doit continuer à fonctionner une fois qu’il est hors écran.
Pensez à la sortie d’une chambre d’hôtel : on ne part pas en laissant les lumières allumées, la télévision en marche et le robinet ouvert. Un composant bien conçu laisse les choses telles qu’il les a trouvées. En React, cela signifie retourner une fonction de nettoyage depuis chaque useEffect qui s’abonne, planifie ou se connecte.
Concevez des caches en pensant au retrait, pas seulement à l’insertion
Les caches partent de bonnes intentions, visant à éviter des appels répétés à l’API ou des calculs coûteux. Des mois plus tard, ils peuvent contenir des milliers d’objets que personne n’a consultés depuis des semaines. Lorsque vous en concevez un, réfléchissez à la manière dont les éléments quittent le cache avec autant de soin que celle dont ils y entrent :
- Pendant combien de temps un élément doit-il rester en mémoire ?
- Quelle est la taille maximale autorisée ?
- Les éléments doivent-ils expirer automatiquement ?
- Les éléments peu utilisés peuvent-ils être éliminés ?
Si ces questions n’ont pas de réponse, le cache va presque certainement croître avec le temps.
Conservez uniquement les données dont vous avez réellement besoin
Un autre problème fréquent consiste à conserver des objets entiers alors qu’une petite partie seulement est utilisée. Si vous récupérez un profil volumineux uniquement pour afficher un nom d’utilisateur, il n’y a aucune raison de conserver la réponse complète indéfiniment ; stockez uniquement les champs nécessaires à l’interface utilisateur. Les objets plus petits consomment moins de mémoire, sont plus faciles à gérer et ont moins de chances d’être conservés par erreur. Parfois, la solution n’est pas d’écrire plus de code, mais de stocker moins de données.
Tenez compte des fuites mineures
Il est tentant de négliger une fuite de quelques kilooctets. Le problème, c’est que les utilisateurs ne font rarement quoi que ce soit qu’une seule fois. Un tableau de bord utilisé toute la journée, un outil d’administration interne partagé par des centaines de collaborateurs, ou une interface de surveillance que personne ne rechargement jamais font exécuter les mêmes chemins de code des milliers de fois. Une fuite à peine mesurable aujourd’hui peut devenir un véritable incident après des semaines d’utilisation normale.
Intégrer des vérifications de mémoire dans le développement quotidien
Les travaux axés sur les performances se concentrent généralement sur le temps de chargement et la latence API, mais la mémoire mérite également une attention particulière. Lors de la création d’une nouvelle fonctionnalité, prenez quelques minutes supplémentaires pour vérifier :
- La mémoire revient-elle à son niveau de base après utilisation de la fonctionnalité ?
- Le nombre d’écouteurs augmente-t-il de manière inattendue ?
- Les nœuds DOM disparaissent-ils une fois les composants supprimés ?
- La répétition de la même action entraîne-t-elle une augmentation constante de la mémoire ?
Ces vérifications sont peu coûteuses et peuvent économiser des heures de débogage par la suite.
Liste de contrôle pour l’examen du code
Au moment de fusionner, passez en revue rapidement cette liste mentale :
- Tous les écouteurs d’événements ajoutés ont-ils également été supprimés ?
- Les temporiseurs sont-ils annulés une fois qu’ils ne sont plus nécessaires ?
- Les abonnements sont-ils supprimés correctement ?
- Ce cache pourrait-il croître indéfiniment ?
Il n’est pas indispensable d’appliquer cela à chaque ligne, mais en faire partie de l’examen réduit considérablement les risques d’envoyer un logiciel avec des fuites.
Points clés
- Les fuites ne provoquent rarement de panne dès le premier jour ; elles s’accumulent progressivement et affectent d’abord vos utilisateurs les plus actifs, ce qui en fait un danger.
- Elles sont également prévisibles : un objet reste actif uniquement parce qu’une référence le pointe encore, donc chaque fuite trouve son origine dans une référence qui aurait dû être supprimée.
- Lorsqu’une application ralentit au fil des heures, évitez de blâmer le navigateur ou le moteur. Prenez une capture du heap, identifiez les objets conservés et suivez la chaîne de leurs références.
- La réponse habituelle est banale : un écouteur oublié, un compte à rebours non résolu, un cache illimité ou un composant qui n’a jamais terminé son nettoyage.
- Un JavaScript rapide ne concerne pas seulement la vitesse d’exécution ; il s’agit aussi de gérer le cycle de vie de ce que l’on alloue, afin que l’application reste réactive que l’on l’utilise cinq minutes ou toute une journée de travail.
Lectures complémentaires
- Les fuites de mémoire dans React Native : suivi du heap JS et des propriétaires de la mémoire native — Découvrez pourquoi le collecteur de déchets ne peut pas protéger une application React Native des fuites natives, et comment identifier ce qui maintient en vie les callbacks, les objets JSI et les images décodées.
- Le cache de cartes sources de Node constitue une fuite mémoire silencieuse en mode développement — Découvrez pourquoi l’activation de --enable-source-maps ou de NODE_V8_COVERAGE peut provoquer une croissance illimitée de la mémoire en raison de appels répétés à eval, ainsi que comment le diagnostiquer et l’atténuer dès aujourd’hui.