Skip to solution
mediumFrontend

How do you handle memory leaks in React?

724 views
01

Understand the problem

Question presented to candidate: "A long-running React app gets slower the more the user navigates around. What kinds of memory leaks does React make easy to write, and how do you prevent them?"

What a strong answer should cover:

  • The single unifying rule: every effect that sets something up must return a cleanup that tears it down.
  • The usual culprits: event listeners, setInterval/setTimeout, WebSocket and observer subscriptions, and in-flight requests.
  • Cleanup runs before the next effect run and on unmount — that ordering is what makes it work.
  • AbortController for fetches; a cancellation flag when the API cannot be aborted.
  • Observers (IntersectionObserver, ResizeObserver, MutationObserver) need explicit disconnect().
  • A subtle one: a closure captured in a long-lived subscription keeps its entire scope alive, including large objects.
  • StrictMode makes this visible in development by mounting, cleaning up, and remounting — an effect that leaks accumulates immediately.
  • Modern nuance: since React 18 a setState on an unmounted component is a silent no-op, so the old "can't perform a React state update on an unmounted component" warning is gone. Its absence does not mean there is no leak.
  • Diagnosis: DevTools Profiler for render cost, Chrome Memory panel heap snapshots and the detached-DOM-node check for actual retention.

Clarifying questions expected:

  • "Is memory actually growing, or is it a re-render performance problem?" — different diagnosis entirely.
  • "Does it get worse with navigation, or over time on one screen?"

Code / implementation expected: Yes — an effect with a cleanup, and the AbortController fetch pattern.

performancehooksdebugging
02

Attempt it yourself

Sketch your approach before reading the solution — that's what interviews test.

Nudge consolestandby

Stuck? Beam a request up — the console returns a conceptual nudge that guides your logic without spoiling the implementation.

03

Study the solution

Target Audience: Engineers preparing for React interviews — assumes useEffect familiarity. Difficulty: Medium

How to read this doc: Concepts are explained in plain language first, then tagged with 📌 Interview term:. The listener counts in section 3 and the unmount behaviour in

Solution ready — 2 min read

Classified // press E to declassify

04

Explore the playground snippets

A leaking effect beside a clean one, with a live listener count
Run Playground
import { useState, useEffect, useRef } from "react";

// Instrument the real APIs so the leak is visible rather than theoretical.
let attached = 0;
const origAdd = window.addEventListener.bind(window);
const origRemove = window.removeEventListener.bind(window);
window.addEventListener = (...args) => { attached++; return origAdd(...args); };
window.removeEventListener = (...args) => { attached--; return origRemove(...args); };

// ❌ Leaks: no cleanup returned, so the listener outlives every unmount.
function Leaky() {
  const [w, setW] = useState(window.innerWidth);
  useEffect(() => {
    window.addEventListener("resize", () => setW(window.innerWidth));
    // no return — nothing is ever removed
  }, []);
  return <span>width {w}</span>;
}

// ✅ Clean: the SAME function reference is added and removed. Two inline
// arrows would be two different functions and would remove nothing.
function Clean() {
  const [w, setW] = useState(window.innerWidth);
  useEffect(() => {
    const onResize = () => setW(window.innerWidth);
    window.addEventListener("resize", onResize);
    return () => window.removeEventListener("resize", onResize);
  }, []);
  return <span>width {w}</span>;
}

// The fetch case: abort cancels the request itself, not just the state write.
function AbortDemo() {
  const [status, setStatus] = useState("idle");
  useEffect(() => {
    const controller = new AbortController();
    setStatus("requesting…");
    fetch("https://example.com/slow", { signal: controller.signal })
      .then(() => setStatus("done"))
      .catch((e) => setStatus(e.name === "AbortError" ? "aborted on unmount" : "failed (expected offline)"));
    return () => controller.abort();
  }, []);
  return <span>fetch: {status}</span>;
}

export default function App() {
  const [showLeaky, setShowLeaky] = useState(false);
  const [showClean, setShowClean] = useState(false);
  const [showFetch, setShowFetch] = useState(false);
  const [, force] = useState(0);
  const cycles = useRef({ leaky: 0, clean: 0 });

  const row = { display: "flex", gap: 10, alignItems: "center", marginBottom: 10 };

  return (
    <div style={{ padding: 24, fontFamily: "system-ui", lineHeight: 1.6 }}>
      <p style={{ background: "#fff3cd", padding: 10, borderRadius: 6 }}>
        Listeners currently attached to window: <strong>{attached}</strong>{" "}
        <button onClick={() => force((n) => n + 1)}>refresh count</button>
      </p>

      <div style={row}>
        <button onClick={() => { if (!showLeaky) cycles.current.leaky++; setShowLeaky(!showLeaky); }}>
          {showLeaky ? "Unmount" : "Mount"} Leaky ({cycles.current.leaky} mounts)
        </button>
        {showLeaky && <Leaky />}
      </div>

      <div style={row}>
        <button onClick={() => { if (!showClean) cycles.current.clean++; setShowClean(!showClean); }}>
          {showClean ? "Unmount" : "Mount"} Clean ({cycles.current.clean} mounts)
        </button>
        {showClean && <Clean />}
      </div>

      <div style={row}>
        <button onClick={() => setShowFetch(!showFetch)}>
          {showFetch ? "Unmount" : "Mount"} AbortDemo
        </button>
        {showFetch && <AbortDemo />}
      </div>

      <p style={{ color: "#666", fontSize: 13 }}>
        Mount and unmount Leaky several times, then hit refresh count — it climbs
        and never comes back down. Do the same with Clean and it always returns
        to where it started.
      </p>
    </div>
  );
}
05

Join the discussion

Discussion (0)

Sign in to join the discussion.

No responses yet. Be the first to share what you think.

Transmission complete // awaiting log

KEEP THE
STREAK ALIVE.

Dossier 51 of 119 decoded in the React.js track. One more won't hurt.

Back to track