Aller au contenu principal
MEWA STUDIO

Memory leaks et cleanup patterns : pourquoi vos animations ralentissent après 5 minutes

Publié le 10 avril 2026|9 min de lecture
performanceJavaScriptanimation

Les fuites mémoire sont la cause n°1 des sites qui ralentissent au fil du temps. Event listeners oubliés, requestAnimationFrame non annulés, observers jamais déconnectés : anatomie des leaks les plus courants et les patterns pour les éliminer.

Chevrons vert foncé alignés en diagonale sur un fond vert clair

Votre site est fluide au chargement. Les animations tournent à 60 fps. Le scroll est réactif. Puis au bout de 3 à 5 minutes de navigation, tout se dégrade. Les transitions saccadent. Le scroll devient poisseux. L'onglet consomme 800 Mo de RAM. L'utilisateur ferme la page sans savoir pourquoi c'était devenu pénible.

Ce scénario n'est pas un bug rare. C'est le symptôme le plus fréquent des fuites mémoire (memory leaks) dans les applications web modernes. Et il touche particulièrement les sites riches en animations, en transitions et en interactions dynamiques.

Le problème est rarement visible en développement. Les développeurs rechargent la page toutes les 30 secondes pendant qu'ils codent. Les tests automatisés vérifient un état instantané, pas une dégradation progressive. Le leak ne se révèle qu'en conditions réelles, quand un utilisateur navigue pendant plusieurs minutes sans recharger. Exactement le scénario que personne ne teste.

Selon les données de HTTP Archive (opens in a new tab), le poids médian du JavaScript chargé par une page a dépassé 650 Ko compressés en 2025. Plus de code signifie plus d'objets en mémoire, plus d'event listeners, plus d'observers et plus d'opportunités de fuites. Les frameworks SPA (React, Next.js, Nuxt) aggravent le problème : la page ne se recharge jamais, donc les leaks s'accumulent indéfiniment.

Aujourd'hui, on dissèque les cinq sources de memory leaks les plus courantes dans les sites animés et on pose les cleanup patterns qui les éliminent. Avec du code. Pas de la théorie.

Comment fonctionne (et échoue) le garbage collector

Avant de corriger des leaks, il faut comprendre pourquoi ils existent. Le garbage collector (GC) de JavaScript libère automatiquement la mémoire des objets qui ne sont plus accessibles depuis la racine du programme (le scope global, la pile d'appels active). Si un objet n'est référencé par rien, le GC le supprime.

Un memory leak se produit quand un objet devrait être libéré mais reste accessible via une référence que le développeur a oublié de couper. Le GC ne peut pas deviner l'intention. Si une référence existe, l'objet reste en mémoire. Indéfiniment.

Les cas classiques : un event listener attaché au window qui référence un composant déjà détruit. Un setInterval qui continue de tourner après un changement de page. Un IntersectionObserver qui observe des éléments retirés du DOM. Dans chaque cas, le GC voit une référence valide. Il ne touche à rien. La mémoire grimpe.

Leak n°1 : les event listeners orphelins

C'est le leak le plus répandu. Un composant ajoute un listener au window, au document ou à un élément parent lors de son montage, mais ne le retire jamais lors de son démontage. À chaque navigation dans une SPA, un nouveau listener s'empile sur le précédent.

Au bout de 10 navigations, vous avez 10 handlers de scroll qui s'exécutent en parallèle. Au bout de 50, le thread principal est saturé. Le site rame. Et chaque handler maintient une closure qui retient en mémoire l'intégralité du scope du composant qui l'a créé, y compris les éléments DOM détachés.

tsx
// Ce composant fuit à chaque démontage
function ParallaxSection({ speed }: { speed: number }) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const handleScroll = () => {
      if (!ref.current) return;
      const y = window.scrollY * speed;
      ref.current.style.transform = `translateY(${y}px)`;
    };

    // Le listener est ajouté...
    window.addEventListener('scroll', handleScroll);

    // ...mais jamais retiré. Chaque montage empile un nouveau handler.
  }, [speed]);

  return <div ref={ref}>...</div>;
}

Le leak classique : listener sans cleanup

tsx
function ParallaxSection({ speed }: { speed: number }) {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const handleScroll = () => {
      if (!ref.current) return;
      const y = window.scrollY * speed;
      ref.current.style.transform = `translateY(${y}px)`;
    };

    window.addEventListener('scroll', handleScroll, { passive: true });

    // Le cleanup retire le listener exact au démontage
    return () => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, [speed]);

  return <div ref={ref}>...</div>;
}

La correction : return du cleanup dans useEffect

Point critique : removeEventListener doit recevoir la même référence de fonction que addEventListener. Si vous passez une arrow function anonyme à addEventListener, vous ne pourrez jamais la retirer. Stockez toujours le handler dans une variable.

Le piège de l'AbortController oublié

Une alternative propre pour gérer plusieurs listeners d'un coup : l'AbortController. Un seul abort() détache tous les listeners enregistrés avec le même signal.

tsx
useEffect(() => {
  const controller = new AbortController();
  const { signal } = controller;

  window.addEventListener('scroll', handleScroll, { signal, passive: true });
  window.addEventListener('resize', handleResize, { signal });
  document.addEventListener('visibilitychange', handleVisibility, { signal });

  // Un seul appel détache les 3 listeners
  return () => controller.abort();
}, []);

AbortController : un seul abort() pour tout nettoyer

Leak n°2 : requestAnimationFrame sans annulation

requestAnimationFrame (rAF) est la base de toute animation fluide en JavaScript. Il synchronise le code avec le cycle de rendu du navigateur à ~60 fps. Le problème : un rAF récursif qui n'est jamais annulé crée une boucle infinie qui survit au démontage du composant.

Le résultat : une animation invisible continue de tourner en arrière-plan, calculant des positions pour des éléments qui n'existent plus. Chaque frame consomme du CPU pour rien. Et la closure du callback retient tous les objets qu'elle référence.

tsx
function FloatingElement() {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    let startTime = performance.now();

    const animate = (timestamp: number) => {
      if (!ref.current) return; // Semble safe, mais ne l'est pas
      const elapsed = timestamp - startTime;
      const y = Math.sin(elapsed * 0.002) * 20;
      ref.current.style.transform = `translateY(${y}px)`;

      // Chaque frame planifie la suivante. À l'infini.
      requestAnimationFrame(animate);
    };

    requestAnimationFrame(animate);
    // Aucun cleanup. La boucle tourne même après démontage.
  }, []);

  return <div ref={ref}>...</div>;
}

Le leak : boucle rAF sans cancelAnimationFrame

Le if (!ref.current) return semble protéger contre l'exécution post-démontage. En réalité, il empêche juste l'écriture sur un élément null. Le requestAnimationFrame(animate) de la ligne précédente a déjà planifié le prochain frame avant que le check ne s'exécute. La boucle continue, elle ne fait juste plus rien de visible. Le CPU et la mémoire sont consommés pour rien.

tsx
function FloatingElement() {
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    let frameId: number;
    let startTime = performance.now();

    const animate = (timestamp: number) => {
      if (!ref.current) return;
      const elapsed = timestamp - startTime;
      const y = Math.sin(elapsed * 0.002) * 20;
      ref.current.style.transform = `translateY(${y}px)`;

      frameId = requestAnimationFrame(animate);
    };

    frameId = requestAnimationFrame(animate);

    return () => cancelAnimationFrame(frameId);
  }, []);

  return <div ref={ref}>...</div>;
}

La correction : cancelAnimationFrame au démontage

Leak n°3 : setInterval et setTimeout fantômes

Les timers sont la source de leaks la plus facile à introduire et la plus difficile à détecter. Un setInterval oublié continue de s'exécuter indéfiniment après le démontage. Un setTimeout avec un délai long (5 secondes, 10 secondes) s'exécute sur un composant qui n'existe plus.

Le cas typique : un carrousel automatique avec un setInterval de 4 secondes pour faire défiler les slides. L'utilisateur change de page. Le carrousel continue de manipuler des éléments DOM détachés toutes les 4 secondes. En arrière-plan.

tsx
function AutoCarousel({ slides }: { slides: Slide[] }) {
  const [current, setCurrent] = useState(0);

  useEffect(() => {
    // Ce timer survit au démontage du composant
    setInterval(() => {
      setCurrent(prev => (prev + 1) % slides.length);
    }, 4000);
  }, [slides.length]);

  return <div>{slides[current].content}</div>;
}

Le leak : carrousel avec setInterval non nettoyé

tsx
function AutoCarousel({ slides }: { slides: Slide[] }) {
  const [current, setCurrent] = useState(0);

  useEffect(() => {
    const intervalId = setInterval(() => {
      setCurrent(prev => (prev + 1) % slides.length);
    }, 4000);

    return () => clearInterval(intervalId);
  }, [slides.length]);

  return <div>{slides[current].content}</div>;
}

La correction : clearInterval systématique

La règle est simple : chaque setInterval doit avoir son clearInterval. Chaque setTimeout doit avoir son clearTimeout. Sans exception. Même si vous pensez que le composant ne sera jamais démonté. Les SPA naviguent. Les composants conditionnels apparaissent et disparaissent. Les onglets sont fermés. Le cleanup n'est pas optionnel.

Leak n°4 : les observers jamais déconnectés

Les API d'observation (IntersectionObserver, ResizeObserver, MutationObserver) sont devenues essentielles pour les animations modernes. Scroll-triggered animations, lazy loading, layouts réactifs : elles sont partout. Et elles leakent si on ne les déconnecte pas.

Un IntersectionObserver qui observe un élément retiré du DOM ne libère pas automatiquement la mémoire. L'observer maintient une référence interne vers l'élément. L'élément maintient une référence vers son sous-arbre DOM complet. Tout reste en mémoire.

tsx
function FadeInSection({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
        }
      },
      { threshold: 0.1 }
    );

    if (ref.current) {
      observer.observe(ref.current);
    }

    // Pas de cleanup. L'observer survit au composant.
  }, []);

  return (
    <div ref={ref} className={isVisible ? 'fade-in' : 'opacity-0'}>
      {children}
    </div>
  );
}

Le leak : IntersectionObserver sans disconnect

tsx
function FadeInSection({ children }: { children: React.ReactNode }) {
  const ref = useRef<HTMLDivElement>(null);
  const [isVisible, setIsVisible] = useState(false);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;

    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setIsVisible(true);
          observer.unobserve(element); // Bonus : arrête d'observer une fois visible
        }
      },
      { threshold: 0.1 }
    );

    observer.observe(element);

    return () => observer.disconnect();
  }, []);

  return (
    <div ref={ref} className={isVisible ? 'fade-in' : 'opacity-0'}>
      {children}
    </div>
  );
}

La correction : disconnect au démontage

Notez le double nettoyage : unobserve quand l'animation est déclenchée (inutile de continuer à observer) et disconnect au démontage (filet de sécurité). C'est un pattern défensif qui couvre les deux cas.

Leak n°5 : les librairies d'animation non détruites

GSAP, Framer Motion, Anime.js, Lottie : les librairies d'animation créent des objets internes (timelines, tweens, instances de rendu) qui ne sont pas gérés par React. Si vous ne les détruisez pas explicitement, elles persistent en mémoire avec toutes leurs références.

GSAP est particulièrement concerné. Une timeline GSAP maintient des références vers chaque élément DOM qu'elle anime. Si le composant est démonté sans kill(), la timeline survit et empêche le GC de libérer les éléments.

tsx
function HeroAnimation() {
  const titleRef = useRef<HTMLHeadingElement>(null);
  const subtitleRef = useRef<HTMLParagraphElement>(null);

  useEffect(() => {
    // Cette timeline survit au composant
    gsap.timeline()
      .from(titleRef.current, { y: 50, opacity: 0, duration: 1 })
      .from(subtitleRef.current, { y: 30, opacity: 0, duration: 0.8 }, '-=0.5');
  }, []);

  return (
    <section>
      <h1 ref={titleRef}>Titre</h1>
      <p ref={subtitleRef}>Sous-titre</p>
    </section>
  );
}

Le leak : timeline GSAP sans kill

tsx
function HeroAnimation() {
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const ctx = gsap.context(() => {
      gsap.timeline()
        .from('h1', { y: 50, opacity: 0, duration: 1 })
        .from('p', { y: 30, opacity: 0, duration: 0.8 }, '-=0.5');
    }, containerRef); // Scope toutes les animations au container

    // revert() tue toutes les animations du contexte et nettoie les styles inline
    return () => ctx.revert();
  }, []);

  return (
    <div ref={containerRef}>
      <h1>Titre</h1>
      <p>Sous-titre</p>
    </div>
  );
}

La correction : gsap.context() et revert()

gsap.context() est la méthode officielle depuis GSAP 3.11. Elle encapsule toutes les animations créées dans le callback et les nettoie proprement avec revert(). C'est le seul pattern qui garantit zéro leak avec GSAP dans React.

Pour Framer Motion, le cleanup est automatique si vous utilisez les composants motion.*. Mais si vous utilisez l'API impérative (animate() de motion), vous devez annuler les contrôles retournés :

tsx
useEffect(() => {
  const controls = animate(element, { opacity: 1 }, { duration: 2 });

  return () => controls.stop();
}, []);

Cleanup Framer Motion avec l'API impérative

Diagnostiquer les leaks avec Chrome DevTools

Identifier un leak demande une méthode. Ouvrir le Performance Monitor et regarder la mémoire monter n'est pas un diagnostic. Voici le protocole précis.

Le test des 3 snapshots

Ouvrez Chrome DevTools, onglet Memory. Le protocole :

  • Snapshot 1 : état initial. Chargez la page. Attendez 5 secondes. Prenez un heap snapshot. C'est votre baseline.
  • Action : provoquez le leak. Naviguez vers une page avec des animations. Revenez à la page d'origine. Répétez 5 fois. Chaque aller-retour devrait créer puis détruire les mêmes composants.
  • Snapshot 2 : état post-navigation. Forcez un garbage collection (bouton poubelle dans DevTools). Prenez un second snapshot.
  • Comparaison. Sélectionnez le snapshot 2 et filtrez par "Objects allocated between Snapshot 1 and Snapshot 2". Si des objets persistent (éléments DOM, closures, observers), vous avez un leak. Le nombre d'objets devrait être proche de zéro après le GC forcé.

Le Performance Monitor en temps réel

Ouvrez Performance Monitor (Ctrl+Shift+P, tapez "Performance Monitor"). Observez trois métriques pendant que vous naviguez :

  • JS heap size. Si elle monte sans redescendre à chaque navigation, c'est un leak mémoire.
  • DOM Nodes. Si le compteur augmente sans revenir à sa valeur initiale, des nœuds détachés s'accumulent. C'est typiquement un observer ou un event listener qui maintient une référence.
  • Event Listeners. Si le nombre grimpe à chaque navigation sans baisser, vous empilez des listeners. C'est le leak n°1 de cet article.

Traquer les nœuds DOM détachés

Dans la console Chrome, tapez getEventListeners(window). Cette commande liste tous les event listeners attachés au window. Si vous voyez 15 handlers scroll alors que votre page n'en utilise qu'un, vous avez trouvé le leak. Chaque handler en trop est un composant qui n'a pas nettoyé derrière lui.

Le pattern universel : le hook useCleanup

Plutôt que de reproduire la même logique de cleanup dans chaque composant, centralisez le pattern. Voici un hook qui gère le cycle de vie complet d'une animation : listeners, rAF, timers et observers.

tsx
import { useEffect, useRef, useCallback } from 'react';

type CleanupFn = () => void;

export function useAnimationCleanup() {
  const cleanupsRef = useRef<CleanupFn[]>([]);

  const addCleanup = useCallback((fn: CleanupFn) => {
    cleanupsRef.current.push(fn);
  }, []);

  const trackRAF = useCallback((id: number) => {
    addCleanup(() => cancelAnimationFrame(id));
    return id;
  }, [addCleanup]);

  const trackInterval = useCallback((id: ReturnType<typeof setInterval>) => {
    addCleanup(() => clearInterval(id));
    return id;
  }, [addCleanup]);

  const trackObserver = useCallback((observer: { disconnect: () => void }) => {
    addCleanup(() => observer.disconnect());
    return observer;
  }, [addCleanup]);

  useEffect(() => {
    return () => {
      cleanupsRef.current.forEach(fn => fn());
      cleanupsRef.current = [];
    };
  }, []);

  return { addCleanup, trackRAF, trackInterval, trackObserver };
}

useAnimationCleanup.ts

tsx
function AnimatedDashboard() {
  const ref = useRef<HTMLDivElement>(null);
  const { trackRAF, trackInterval, trackObserver, addCleanup } = useAnimationCleanup();

  useEffect(() => {
    if (!ref.current) return;

    // rAF tracké automatiquement
    const animate = () => {
      // ... logique d'animation
      trackRAF(requestAnimationFrame(animate));
    };
    trackRAF(requestAnimationFrame(animate));

    // Interval tracké
    trackInterval(setInterval(() => {
      // ... mise à jour périodique
    }, 5000));

    // Observer tracké
    const observer = trackObserver(
      new ResizeObserver((entries) => {
        // ... ajustement au resize
      })
    );
    observer.observe(ref.current);

    // Listener avec AbortController
    const controller = new AbortController();
    window.addEventListener('scroll', handleScroll, {
      signal: controller.signal,
      passive: true
    });
    addCleanup(() => controller.abort());

  }, [trackRAF, trackInterval, trackObserver, addCleanup]);

  return <div ref={ref}>...</div>;
}

Utilisation du hook dans un composant animé

Checklist anti-leak pour chaque composant animé

Avant de merger un composant qui contient de l'animation ou de l'interactivité, passez en revue cette liste :

  • Event listeners. Chaque addEventListener a son removeEventListener dans le cleanup (ou un AbortController).
  • requestAnimationFrame. Chaque boucle rAF stocke l'ID du frame et appelle cancelAnimationFrame au démontage.
  • Timers. Chaque setInterval a son clearInterval. Chaque setTimeout a son clearTimeout.
  • Observers. Chaque IntersectionObserver, ResizeObserver et MutationObserver appelle disconnect() au démontage.
  • Librairies tierces. GSAP : ctx.revert(). Framer Motion impératif : controls.stop(). Lottie : animation.destroy(). Three.js : renderer.dispose().
  • Fetch et WebSocket. Les requêtes en cours au démontage doivent être annulées via AbortController. Les WebSockets doivent être fermés.
  • Références DOM dans les closures. Si un callback stocké hors du composant (dans un store, un event bus) référence un élément DOM, la référence doit être nettoyée au démontage.

La mémoire est un budget, pas une ressource infinie

Sur desktop, un onglet Chrome qui consomme 500 Mo est désagréable. Sur mobile, c'est un arrêt de mort. Les navigateurs mobiles tuent les onglets qui dépassent leur allocation mémoire sans prévenir. Pas d'erreur. Pas de fallback. L'onglet est simplement rechargé et l'utilisateur perd son contexte.

Selon les données de web.dev (opens in a new tab), le seuil critique sur mobile se situe entre 100 et 200 Mo selon l'appareil. Un site avec des animations non nettoyées atteint ce seuil en quelques minutes de navigation active.

Le cleanup n'est pas du perfectionnisme. C'est la différence entre un site qui fonctionne pendant toute la session d'un utilisateur et un site qui se dégrade silencieusement jusqu'à devenir inutilisable. Le premier retient ses visiteurs. Le second les perd sans même générer une erreur dans la console.

Chaque useEffect sans return est une dette technique. Chaque animation sans cleanup est une bombe à retardement. Le code qui s'exécute est visible. Le code qui oublie de s'arrêter est invisible. Et c'est précisément pour ça qu'il cause autant de dégâts.