Skip to main content
MEWA STUDIO

Memory Leaks and Cleanup Patterns : Why Your Animations Slow Down After 5 Minutes

Published on April 10, 2026|8 min read
performanceJavaScriptanimation

Memory leaks are the #1 cause of websites that degrade over time. Forgotten event listeners, uncancelled requestAnimationFrame loops, disconnected observers: anatomy of the most common leaks and the patterns to eliminate them.

Dark green chevrons aligned diagonally on a light green background

Your site is smooth on load. Animations run at 60 fps. Scrolling is responsive. Then after 3 to 5 minutes of browsing, everything degrades. Transitions stutter. Scrolling feels sluggish. The tab consumes 800 MB of RAM. The user closes the page without knowing why it became painful.

This scenario isn't a rare bug. It's the most common symptom of memory leaks in modern web applications. And it disproportionately affects sites rich in animations, transitions and dynamic interactions.

The problem is rarely visible during development. Developers reload the page every 30 seconds while coding. Automated tests verify an instant state, not progressive degradation. The leak only reveals itself under real conditions, when a user browses for several minutes without reloading. Exactly the scenario nobody tests.

According to data from HTTP Archive (opens in a new tab), the median JavaScript weight loaded per page exceeded 650 KB compressed in 2025. More code means more objects in memory, more event listeners, more observers and more opportunities for leaks. SPA frameworks (React, Next.js, Nuxt) make it worse : the page never reloads, so leaks accumulate indefinitely.

Today, we dissect the five most common sources of memory leaks in animated sites and lay out the cleanup patterns that eliminate them. With code. Not theory.

How the garbage collector works (and fails)

Before fixing leaks, you need to understand why they exist. JavaScript's garbage collector (GC) automatically frees memory from objects that are no longer reachable from the program's root (the global scope, the active call stack). If an object is referenced by nothing, the GC removes it.

A memory leak occurs when an object should be freed but remains reachable through a reference the developer forgot to cut. The GC can't guess intent. If a reference exists, the object stays in memory. Indefinitely.

The classic cases : an event listener attached to window that references a destroyed component. A setInterval that keeps running after a page change. An IntersectionObserver watching elements removed from the DOM. In each case, the GC sees a valid reference. It doesn't touch anything. Memory climbs.

Leak #1 : orphaned event listeners

This is the most widespread leak. A component adds a listener to window, document or a parent element on mount but never removes it on unmount. With each navigation in a SPA, a new listener stacks on top of the previous one.

After 10 navigations, you have 10 scroll handlers executing in parallel. After 50, the main thread is saturated. The site lags. And each handler maintains a closure that retains the entire scope of the component that created it, including detached DOM elements.

tsx
// This component leaks on every unmount
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)`;
    };

    // Listener is added...
    window.addEventListener('scroll', handleScroll);

    // ...but never removed. Each mount stacks a new handler.
  }, [speed]);

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

The classic leak: listener without 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 });

    // Cleanup removes the exact listener on unmount
    return () => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, [speed]);

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

The fix: return cleanup in useEffect

Critical point : removeEventListener must receive the same function reference as addEventListener. If you pass an anonymous arrow function to addEventListener, you can never remove it. Always store the handler in a variable.

The forgotten AbortController trap

A clean alternative for managing multiple listeners at once : the AbortController. A single abort() detaches all listeners registered with the same 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 });

  // A single call detaches all 3 listeners
  return () => controller.abort();
}, []);

AbortController: one abort() to clean up everything

Leak #2 : requestAnimationFrame without cancellation

requestAnimationFrame (rAF) is the foundation of any smooth JavaScript animation. It synchronizes code with the browser's render cycle at ~60 fps. The problem : a recursive rAF that's never cancelled creates an infinite loop that survives component unmount.

The result : an invisible animation keeps running in the background, calculating positions for elements that no longer exist. Each frame consumes CPU for nothing. And the callback's closure retains every object it references.

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

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

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

      // Each frame schedules the next one. Forever.
      requestAnimationFrame(animate);
    };

    requestAnimationFrame(animate);
    // No cleanup. The loop runs even after unmount.
  }, []);

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

The leak: rAF loop without cancelAnimationFrame

The if (!ref.current) return seems to protect against post-unmount execution. In reality, it only prevents writing to a null element. The requestAnimationFrame(animate) on the previous line has already scheduled the next frame before the check runs. The loop continues, it just no longer does anything visible. CPU and memory are consumed for nothing.

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>;
}

The fix: cancelAnimationFrame on unmount

Leak #3 : ghost setInterval and setTimeout

Timers are the easiest leak to introduce and the hardest to detect. A forgotten setInterval keeps executing indefinitely after unmount. A setTimeout with a long delay (5 seconds, 10 seconds) fires on a component that no longer exists.

The typical case : an auto-playing carousel with a 4-second setInterval to cycle through slides. The user navigates away. The carousel keeps manipulating detached DOM elements every 4 seconds. In the background.

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

  useEffect(() => {
    // This timer survives component unmount
    setInterval(() => {
      setCurrent(prev => (prev + 1) % slides.length);
    }, 4000);
  }, [slides.length]);

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

The leak: carousel with uncleaned setInterval

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>;
}

The fix: systematic clearInterval

The rule is simple : every setInterval must have its clearInterval. Every setTimeout must have its clearTimeout. No exceptions. Even if you think the component will never unmount. SPAs navigate. Conditional components appear and disappear. Tabs get closed. Cleanup is not optional.

Leak #4 : observers never disconnected

Observation APIs (IntersectionObserver, ResizeObserver, MutationObserver) have become essential for modern animations. Scroll-triggered animations, lazy loading, responsive layouts : they're everywhere. And they leak if you don't disconnect them.

An IntersectionObserver watching an element removed from the DOM doesn't automatically free memory. The observer maintains an internal reference to the element. The element maintains a reference to its entire DOM subtree. Everything stays in memory.

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);
    }

    // No cleanup. The observer outlives the component.
  }, []);

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

The leak: IntersectionObserver without 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: stop observing once visible
        }
      },
      { threshold: 0.1 }
    );

    observer.observe(element);

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

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

The fix: disconnect on unmount

Note the double cleanup : unobserve when the animation triggers (no point in continuing to observe) and disconnect on unmount (safety net). This is a defensive pattern that covers both cases.

Leak #5 : animation libraries not destroyed

GSAP, Framer Motion, Anime.js, Lottie : animation libraries create internal objects (timelines, tweens, render instances) that are not managed by React. If you don't destroy them explicitly, they persist in memory with all their references.

GSAP is particularly affected. A GSAP timeline maintains references to every DOM element it animates. If the component unmounts without kill(), the timeline survives and prevents the GC from freeing the elements.

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

  useEffect(() => {
    // This timeline outlives the component
    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}>Title</h1>
      <p ref={subtitleRef}>Subtitle</p>
    </section>
  );
}

The leak: GSAP timeline without 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); // Scopes all animations to the container

    // revert() kills all animations in the context and cleans inline styles
    return () => ctx.revert();
  }, []);

  return (
    <div ref={containerRef}>
      <h1>Title</h1>
      <p>Subtitle</p>
    </div>
  );
}

The fix: gsap.context() and revert()

gsap.context() has been the official method since GSAP 3.11. It encapsulates all animations created within the callback and cleans them up properly with revert(). It's the only pattern that guarantees zero leaks with GSAP in React.

For Framer Motion, cleanup is automatic if you use motion.* components. But if you use the imperative API (animate() from motion), you must cancel the returned controls :

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

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

Framer Motion cleanup with the imperative API

Diagnosing leaks with Chrome DevTools

Identifying a leak requires a method. Opening the Performance Monitor and watching memory climb isn't a diagnosis. Here's the precise protocol.

The 3-snapshot test

Open Chrome DevTools, Memory tab. The protocol :

  • Snapshot 1 : initial state. Load the page. Wait 5 seconds. Take a heap snapshot. This is your baseline.
  • Action : trigger the leak. Navigate to a page with animations. Go back to the original page. Repeat 5 times. Each round trip should create then destroy the same components.
  • Snapshot 2 : post-navigation state. Force a garbage collection (trash can button in DevTools). Take a second snapshot.
  • Comparison. Select snapshot 2 and filter by "Objects allocated between Snapshot 1 and Snapshot 2". If objects persist (DOM elements, closures, observers), you have a leak. The object count should be close to zero after the forced GC.

Real-time Performance Monitor

Open Performance Monitor (Ctrl+Shift+P, type "Performance Monitor"). Watch three metrics as you navigate :

  • JS heap size. If it climbs without dropping back on each navigation, that's a memory leak.
  • DOM Nodes. If the count increases without returning to its initial value, detached nodes are accumulating. This is typically an observer or event listener maintaining a reference.
  • Event Listeners. If the count rises on each navigation without dropping, you're stacking listeners. That's leak #1 from this article.

Tracking detached DOM nodes

In the Chrome console, type getEventListeners(window). This command lists every event listener attached to window. If you see 15 scroll handlers when your page only uses one, you've found the leak. Each extra handler is a component that didn't clean up after itself.

The universal pattern : the useCleanup hook

Rather than reproducing the same cleanup logic in every component, centralize the pattern. Here's a hook that manages the full lifecycle of an animation : listeners, rAF, timers and 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 automatically tracked
    const animate = () => {
      // ... animation logic
      trackRAF(requestAnimationFrame(animate));
    };
    trackRAF(requestAnimationFrame(animate));

    // Interval tracked
    trackInterval(setInterval(() => {
      // ... periodic update
    }, 5000));

    // Observer tracked
    const observer = trackObserver(
      new ResizeObserver((entries) => {
        // ... resize adjustment
      })
    );
    observer.observe(ref.current);

    // Listener with 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>;
}

Using the hook in an animated component

Anti-leak checklist for every animated component

Before merging a component that contains animation or interactivity, review this list :

  • Event listeners. Every addEventListener has its removeEventListener in the cleanup (or an AbortController).
  • requestAnimationFrame. Every rAF loop stores the frame ID and calls cancelAnimationFrame on unmount.
  • Timers. Every setInterval has its clearInterval. Every setTimeout has its clearTimeout.
  • Observers. Every IntersectionObserver, ResizeObserver and MutationObserver calls disconnect() on unmount.
  • Third-party libraries. GSAP : ctx.revert(). Imperative Framer Motion : controls.stop(). Lottie : animation.destroy(). Three.js : renderer.dispose().
  • Fetch and WebSocket. In-flight requests on unmount must be cancelled via AbortController. WebSockets must be closed.
  • DOM references in closures. If a callback stored outside the component (in a store, an event bus) references a DOM element, the reference must be cleaned on unmount.

Memory is a budget, not an infinite resource

On desktop, a Chrome tab consuming 500 MB is unpleasant. On mobile, it's a death sentence. Mobile browsers kill tabs that exceed their memory allocation without warning. No error. No fallback. The tab simply reloads and the user loses their context.

According to data from web.dev (opens in a new tab), the critical threshold on mobile sits between 100 and 200 MB depending on the device. A site with uncleaned animations hits that threshold within minutes of active browsing.

Cleanup isn't perfectionism. It's the difference between a site that works throughout a user's entire session and one that silently degrades until it becomes unusable. The first retains its visitors. The second loses them without even generating an error in the console.

Every useEffect without a return is technical debt. Every animation without cleanup is a ticking time bomb. The code that runs is visible. The code that forgets to stop is invisible. And that's precisely why it causes so much damage.