Memory Leaks in useEffect: setInterval and addEventListener Without Cleanup - VibeDoctor 
← All Articles 🖥️ Frontend Quality High

Memory Leaks in useEffect: setInterval and addEventListener Without Cleanup

AI tools set up intervals and event listeners in useEffect but forget cleanup functions. Learn how to prevent memory leaks in React.

FE-006

Quick Answer

When you call setInterval or addEventListener inside a useEffect without returning a cleanup function, React continues running those subscriptions after the component unmounts. This causes memory leaks, stale state updates, and "Can't perform a React state update on an unmounted component" errors. The fix is always to return a cleanup function that calls clearInterval or removeEventListener.

Why useEffect Cleanup Matters

React's useEffect hook runs side effects after the component renders. The critical part that AI tools consistently skip is the return value: a function that React calls when the component unmounts or before the effect runs again. Without it, side effects that register listeners or start timers keep running indefinitely in the background.

According to the Chrome DevTools team, memory leaks are a contributing factor in 72% of mobile web application performance degradations. React applications are particularly vulnerable because components mount and unmount frequently - route changes, conditional rendering, and tab switching all trigger unmounts. Each unmounted component with a forgotten cleanup function leaves behind a ghost: a timer firing or a listener responding to events, often trying to call setState on a component that no longer exists in the DOM.

The practical symptoms range from subtle - your app gradually consuming more memory during a long session - to hard crashes where your event handler fires after navigation and tries to update state in a component that has already been garbage-collected by React's internal reconciler.

What AI Code Generators Produce

When you ask Bolt, Lovable, or Cursor to build a live data feed, countdown timer, or window resize handler, the generated code almost always registers the side effect correctly but omits cleanup entirely:

// ❌ BAD - No cleanup: interval keeps firing after unmount
import { useState, useEffect } from 'react';

function LivePriceWidget({ symbol }) {
  const [price, setPrice] = useState(null);

  useEffect(() => {
    const interval = setInterval(async () => {
      const data = await fetchPrice(symbol);
      setPrice(data.price); // setState on unmounted component!
    }, 1000);
    // Missing: return () => clearInterval(interval);
  }, [symbol]);

  return 
{price ?? 'Loading...'}
; }

This component fetches a price every second. When the user navigates away, the component unmounts - but the interval keeps running. Every tick still tries to call setPrice, producing the infamous React warning and potentially causing subtle bugs in whatever component React renders next.

The same pattern occurs with addEventListener:

// ❌ BAD - Event listener accumulates on every re-render
useEffect(() => {
  window.addEventListener('resize', handleResize);
  // Missing: return () => window.removeEventListener('resize', handleResize);
}, []);

If this effect runs with a dependency array that changes, React re-runs the effect, adding a second listener - without ever removing the first. After ten re-renders, you have ten resize handlers firing simultaneously.

The Correct Cleanup Pattern

The fix is mechanical: every useEffect that registers a subscription, starts a timer, opens a connection, or attaches a listener must return a cleanup function:

// ✅ GOOD - Cleanup function prevents memory leak
import { useState, useEffect } from 'react';

function LivePriceWidget({ symbol }) {
  const [price, setPrice] = useState(null);

  useEffect(() => {
    let cancelled = false;

    const interval = setInterval(async () => {
      const data = await fetchPrice(symbol);
      if (!cancelled) {
        setPrice(data.price);
      }
    }, 1000);

    return () => {
      cancelled = true;
      clearInterval(interval);
    };
  }, [symbol]);

  return 
{price ?? 'Loading...'}
; } // ✅ GOOD - addEventListener with matching removeEventListener useEffect(() => { window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); }; }, [handleResize]);

The cancelled flag in the first example is an additional safeguard: even if the async callback inside the interval has already been queued when cleanup runs, it will check the flag before calling setPrice. This handles the race condition between the interval firing and the cleanup function running.

Common useEffect Patterns That Need Cleanup

Side Effect Start Call Required Cleanup
Interval timer setInterval(fn, ms) clearInterval(id)
Timeout setTimeout(fn, ms) clearTimeout(id)
DOM event listener addEventListener(event, fn) removeEventListener(event, fn)
WebSocket connection new WebSocket(url) ws.close()
Supabase realtime supabase.channel().subscribe() supabase.removeChannel(channel)
AbortController fetch fetch(url, { signal }) controller.abort()
IntersectionObserver observer.observe(el) observer.disconnect()

Note that not every useEffect needs cleanup. If you're just setting document title, logging analytics, or triggering a one-time API call that doesn't hold a reference, cleanup may not be required. The rule is: if you registered something, you must unregister it.

React Strict Mode Exposes Missing Cleanup

React 18's Strict Mode deliberately mounts and unmounts every component twice in development. This was specifically designed to surface missing cleanup functions - if your component works incorrectly after a double-mount cycle, you have a leak. Many AI-generated projects disable Strict Mode to "fix" these errors rather than addressing the underlying issue.

According to the React team's documentation, Strict Mode double-invoking effects is the primary mechanism for detecting missing cleanup in the React 18 development workflow. If you see your effects running twice and causing errors, the correct response is to add cleanup - not to remove <React.StrictMode> from your app root.

Next.js projects generated by Bolt, Lovable, or v0 often include <React.StrictMode> by default in the Next.js config, which is why memory leak warnings appear immediately during development of AI-generated apps.

How to Find and Fix Missing Cleanup in Your Codebase

  1. Search for unguarded useEffect calls: Look for useEffect blocks containing setInterval, setTimeout, addEventListener, or subscribe calls. If the block does not end with a return () =>, it likely needs cleanup.
  2. Enable React Strict Mode: In development, React will double-invoke effects. Any leak will manifest immediately as a doubled timer or duplicated event handler.
  3. Check the browser Memory panel: In Chrome DevTools, take heap snapshots before and after navigating through your app. Growing heap size between snapshots indicates retained closures from leaked effects.
  4. Run automated scanning: Tools like VibeDoctor (vibedoctor.io) automatically scan your codebase for useEffect blocks missing cleanup functions (FE-006) and flag specific file paths and line numbers. Free to sign up.
  5. Use the eslint-plugin-react-hooks rule: The react-hooks/exhaustive-deps ESLint rule catches some cleanup issues, though it doesn't specifically enforce cleanup return values.

FAQ

Does every useEffect need a cleanup function?

No. Effects that don't register subscriptions, start timers, or attach listeners don't need cleanup. For example, useEffect(() => { document.title = 'Dashboard'; }, []) has nothing to clean up. The rule applies only when you start something that continues running independently of React's rendering.

What happens if I forget cleanup in a Next.js app?

In a Next.js app with client-side routing, components unmount on every navigation. A missing cleanup means your intervals and listeners accumulate as users navigate. After visiting ten pages, you may have ten background intervals running. This degrades performance and can cause stale data bugs where an old component's interval overwrites state in a newer one.

Can I use AbortController to cancel fetch calls in useEffect?

Yes, and you should. Create an AbortController inside the effect, pass its signal to fetch, and call controller.abort() in the cleanup function. This cancels in-flight requests when the component unmounts, preventing stale responses from updating state after navigation.

Does React Query or SWR handle cleanup automatically?

Yes. Libraries like TanStack Query (React Query) and SWR manage their own subscriptions and cleanup internally. If you're using these libraries for data fetching, you don't need to manually manage cleanup for those fetches. However, any direct setInterval, addEventListener, or WebSocket connections you add alongside them still require manual cleanup.

Is this different in React Server Components?

React Server Components don't run in the browser, so useEffect doesn't exist in them. The memory leak problem applies only to Client Components (files with "use client" at the top in Next.js App Router). If you're building a purely server-rendered page, this issue doesn't apply - but the moment you add interactivity with a Client Component, it does.

Scan your codebase for this issue - free

VibeDoctor checks for FE-006 and 128 other issues across 15 diagnostic areas.

SCAN MY APP →
← Back to all articles View all 129+ checks →