← All Articles 🖥️ Frontend Quality High By Nishant Kumar Verma Updated

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, duplicated timers and listeners, and stale state updates (React 17 and earlier also logged "Can't perform a React state update on an unmounted component"; React 18 removed that warning). 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.

Chrome's DevTools guide to memory problems lists performance that gets progressively worse over time as a possible symptom of a memory leak. 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 visible bugs where an event handler or timer keeps firing after navigation, repeating network requests and work for a component that is no longer on screen.

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 <div>{price ?? 'Loading...'}</div>;
}

This component fetches a price every second. When the user navigates away, the component unmounts - but the interval keeps running. Every tick still fetches a price and calls setPrice on a component that is gone. React 18 no longer warns about this, so nothing in the console tells you the network requests never stop.

The same pattern occurs with addEventListener:

// ❌ BAD - Event listener is never removed
useEffect(() => {
  window.addEventListener('resize', handleResize);
  // Missing: return () => window.removeEventListener('resize', handleResize);
}, []);

Every time the component mounts, it adds a listener that is never removed. If the effect has a dependency that changes (and handleResize is a new function each time), React re-runs it and adds another listener without removing the first. After ten re-runs, 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 <div>{price ?? 'Loading...'}</div>;
}

// ✅ 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

Since React 18, Strict Mode deliberately mounts every component, unmounts it and mounts it again in development. This was specifically designed to surface missing cleanup functions - if your component works incorrectly after that extra cycle, you have a leak. Disabling Strict Mode to "fix" the doubled behaviour hides the underlying issue instead of fixing it.

React's documentation says Strict Mode re-runs Effects an extra time in development to find bugs caused by missing Effect cleanup. 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.

Strict Mode is usually on already: Next.js turns it on by default for the App Router (since 13.5.1), and the Vite React template wraps the app in <StrictMode>. That is why missing cleanup shows up immediately in development of AI-generated apps as doubled timers, listeners and requests.

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's Vibe Check automatically scan your codebase for useEffect blocks that call setInterval, addEventListener or subscribe without a cleanup function (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 keeps writing to shared state, such as a global store or cache, after a newer one has taken over.

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.

Diagnose your codebase - free

VibeDoctor checks for FE-006 and 148 other issues across 21 diagnostic areas - security, performance, code quality, and more.

SCAN MY APP →