DevLift
Back to Blog

Memory Leaks from Forgotten Event Listeners and Timers

Open a modal 10 times without cleanup and you've got 10 stacked timers silently draining memory. Here's what this looks like in a PR and how senior devs catch it.

Admin
July 31, 20266 min read4 views

Memory Leaks from Forgotten Event Listeners and Timers

The app worked fine in development. In staging too. You shipped it, went home, and at 9am the next morning your Slack lit up: the dashboard is crawling, users are force-refreshing, and memory usage on the client is 2GB.

No network errors. No JS exceptions. The JavaScript heap just grew until the browser tab became unusable.

You open Chrome DevTools, take a heap snapshot, and there it is: thousands of IntersectionObserver callbacks, hundreds of resize listeners, and a setInterval still running for a modal that was closed 40 minutes ago.

Welcome to the most invisible bug in frontend engineering.

What This Looks Like in a PR

Here's a component that a reviewer might wave through. It looks reasonable:

function LivePriceWidget({ symbol }: { symbol: string }) {
  const [price, setPrice] = useState<number | null>(null);
 
  useEffect(() => {
    const interval = setInterval(async () => {
      const data = await fetchPrice(symbol);
      setPrice(data.price);
    }, 3000);
 
    window.addEventListener("focus", () => {
      fetchPrice(symbol).then((d) => setPrice(d.price));
    });
  }, [symbol]);
 
  return <div>{price ?? "Loading..."}</div>;
}

Ship this to a user who navigates between 10 different stocks in a session. They've now got 10 active setInterval timers hammering your API and 10 focus event listeners stacked on window. The component is gone — the DOM is gone — but the timers and listeners are very much alive.

Why It's Actually Dangerous

The garbage collector can't collect anything a live reference points to. When your setInterval callback closes over setPrice (a React state setter), it keeps the entire component instance and its associated fiber alive in memory.

This matters more than it used to because:

  • SPAs keep running. A server-rendered page navigates away and everything dies. An SPA keeps the same JS context alive for hours. Your users aren't refreshing; they're navigating.
  • Each remount doubles the damage. Navigating to a route, back, then to it again? Two intervals. Do it five times? Five intervals, all running, all writing to state that no longer belongs to anything on screen.
  • Nothing tells you. This is the part that gets people. React used to warn — "Can't perform a React state update on an unmounted component" — and that warning was removed in React 18 because it fired on plenty of harmless code. On React 19 a leaked interval calling setState after unmount is a silent no-op. No warning, no error, no console noise. The only symptom is the memory graph.
  • Strict Mode is your only free signal. In development, Strict Mode mounts, runs effects, tears them down, and mounts again. If your effect has no cleanup you'll see the work happen twice — two fetches, two subscriptions — which is Strict Mode telling you the teardown path is missing. Take that as the bug report it is, because production won't send you one.

Example 1: The Window Event Listener (The Classic)

This is the one that bites every React developer at least once.

Before:

function SearchModal({ onClose }: { onClose: () => void }) {
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === "Escape") onClose();
    };
    window.addEventListener("keydown", handleKeyDown);
    // no cleanup
  }, [onClose]);
 
  return <div className="modal">...</div>;
}

Open the modal 10 times, close it 10 times. You've got 10 keydown listeners on window, each holding its own captured onClose. Every subsequent Escape press fires all 10 of them — including the nine belonging to modals that no longer exist. Note the dep array makes it worse than it looks: [onClose] re-runs the effect whenever the parent hands down a fresh onClose identity, so listeners pile up on re-render too, not just on remount.

After:

function SearchModal({ onClose }: { onClose: () => void }) {
  useEffect(() => {
    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === "Escape") onClose();
    };
    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [onClose]);
 
  return <div className="modal">...</div>;
}

The returned function is the cleanup. React calls it when the component unmounts or before re-running the effect.

Example 2: The Polling Interval

Dashboard components love polling. They also love leaking.

Before:

function MetricsPanel({ orgId }: { orgId: string }) {
  const [metrics, setMetrics] = useState<Metrics | null>(null);
 
  useEffect(() => {
    setInterval(async () => {
      const data = await fetchMetrics(orgId);
      setMetrics(data);
    }, 5000);
  }, [orgId]);
 
  return <MetricsChart data={metrics} />;
}

Every time orgId changes, a new interval fires up. The old one is never cleared. After switching between 5 orgs you have 5 intervals polling 5 different orgs and all writing to the same metrics state, so what you display is whichever response landed last. React won't say a word about it.

After:

function MetricsPanel({ orgId }: { orgId: string }) {
  const [metrics, setMetrics] = useState<Metrics | null>(null);
 
  useEffect(() => {
    let active = true;
 
    const interval = setInterval(async () => {
      const data = await fetchMetrics(orgId);
      if (active) setMetrics(data);
    }, 5000);
 
    return () => {
      active = false;
      clearInterval(interval);
    };
  }, [orgId]);
 
  return <MetricsChart data={metrics} />;
}

The active flag handles the race condition where the component unmounts while a fetch is in-flight. clearInterval handles the interval. Both matter.

Example 3: Node.js EventEmitter in a Loop

This one shows up in backend code, usually in queue consumers or WebSocket handlers.

Before:

interface JobQueue extends EventEmitter {
  push(job: Job): void;
}
 
async function processJobs(queue: JobQueue, jobs: Job[]) {
  for (const job of jobs) {
    await new Promise<void>((resolve, reject) => {
      queue.on("drain", resolve);
      queue.on("error", reject);
      queue.push(job);
    });
  }
}

Each iteration of the loop adds new drain and error listeners. They're never removed. Process 50 jobs and you have 50 stacked listeners. Node.js defaults to warning you at 11 — MaxListenersExceededWarning: Possible EventEmitter memory leak detected.

After:

async function processJobs(queue: JobQueue, jobs: Job[]) {
  for (const job of jobs) {
    await new Promise<void>((resolve, reject) => {
      const onDrain = () => {
        queue.off("error", onReject);
        resolve();
      };
      const onReject = (err: Error) => {
        queue.off("drain", onDrain);
        reject(err);
      };
 
      queue.once("drain", onDrain);
      queue.once("error", onReject);
      queue.push(job);
    });
  }
}

once removes the listener automatically after it fires. When you use on, manually remove the paired listener in both resolution paths.

Example 4: AbortController for Async Cleanup

Sometimes you're not listening for an event — you're waiting for a fetch that might outlive its caller.

Before:

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
 
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then((r) => r.json())
      .then((data) => setUser(data));
  }, [userId]);
 
  return user ? <Profile user={user} /> : <Spinner />;
}

Navigate away before the fetch resolves and nothing complains — but the request is still in flight, the closure holding setUser is still alive, and the response is still going to be parsed and thrown away. Change userId three times quickly and you have three overlapping requests racing to write the same state.

After:

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [error, setError] = useState<Error | null>(null);
 
  useEffect(() => {
    const controller = new AbortController();
 
    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then((r) => r.json())
      .then((data) => setUser(data))
      .catch((err: unknown) => {
        // The abort we caused ourselves is not an error worth reporting
        if (err instanceof Error && err.name === "AbortError") return;
        setError(err instanceof Error ? err : new Error(String(err)));
      });
 
    return () => controller.abort();
  }, [userId]);
 
  if (error) return <ProfileError error={error} />;
  return user ? <Profile user={user} /> : <Spinner />;
}

AbortController cancels the in-flight request cleanly, and the cleanup runs both on unmount and before the effect re-runs for a new userId — which is what kills the race.

One thing to get right in that catch: handle the non-abort error, don't re-throw it. Writing if (err.name !== "AbortError") throw err; looks like it propagates the failure to the caller. It doesn't. There is no caller — you're inside a .catch on a detached promise chain, so the re-thrown error becomes an unhandled rejection. It won't reach a try/catch, it won't reach an error boundary, and in a browser it lands in window.onunhandledrejection, which most apps never wire up. So a rule of thumb: if a .catch re-throws and nothing downstream is awaiting it, you have written a silent failure. Put the error in state, or hand it to showBoundary from react-error-boundary.

How to Catch This in Code Review

This is the checklist I run on any useEffect or event registration I see in a PR:

In React code:

  • Does the useEffect return a cleanup function? If it starts a timer, subscribes to anything, or attaches a listener — it must.
  • Is there a matching removeEventListener for every addEventListener? Named function reference, not an inline arrow (you can't remove an anonymous function).
  • Does clearInterval/clearTimeout happen in the cleanup? Not just somewhere else in the component.
  • Are there EventEmitter.on() calls without corresponding off() or once()?

In Node.js/server code:

  • Does any loop add event listeners? That's almost always a leak.
  • Is MaxListenersExceededWarning being silenced with emitter.setMaxListeners(Infinity) instead of fixing the root cause? That's a red flag, not a fix.

Pattern to grep for:

The instinct is to pipe grep into grep -v, and it does not work — grep -v filters lines, and the line that calls addEventListener is never the line that calls removeEventListener, so every file that registers a listener survives the filter whether or not it cleans up. You need a file-level check: list the files that subscribe, then subtract the files that also unsubscribe.

# Files that register something but never tear anything down
grep -rlE "addEventListener|setInterval|\.on\(" --include="*.ts" --include="*.tsx" src \
  | xargs grep -LE "removeEventListener|clearInterval|clearTimeout|\.off\(|AbortController"

-l lists files that match, -L lists files that don't — that inversion is the whole trick. Note also that src/**/*.tsx does not recurse in bash unless you've run shopt -s globstar; it silently matches only one directory deep, which is why the command above uses grep -r on the directory instead.

This is still a starting point, not a linter. A file that registers in one component and cleans up in another passes, and a file with four effects and one cleanup passes. Use it to build the list of files worth reading.

One more signal: if a component uses // eslint-disable-next-line react-hooks/exhaustive-deps to silence missing deps, look harder. Deps are often left out specifically because adding them would reveal that the effect needs a cleanup.

🚨
Every useEffect that starts something — a timer, a listener, a subscription, a fetch — must return a function that ends it. If it doesn't have a return, it's probably leaking.

Comments (0)

No comments yet. Be the first to share your thoughts!

Related Articles

`forEach` doesn't return a Promise, so `await` before it resolves instantly. Every async callback fires and is forgotten — this is how batch processors ship broken.
AdminJuly 31, 20265 min read
Picture this: a live dashboard that polls for new notifications every 3 seconds. The count increments in state, the UI updates, everything looks right — except the interval callback is silently reading count = 0 on every tick, because it…
AdminJuly 31, 20266 min read
Build a pub/sub system from 60 lines of JavaScript: subscribe, publish, unsubscribe, once(), namespace wildcards, error isolation, and async publish.
AdminJuly 31, 20266 min read