Memory LeaksReactGarbage CollectionuseEffectDevTools

Memory Leaks & React-Specific Issues

Learn what causes memory leaks in JavaScript and React apps, how to detect them with Chrome DevTools heap snapshots, and how to fix the most common patterns — from forgotten event listeners to missing useEffect cleanup.

30 min read14 sections
01

Overview

A memory leak happens when your application allocates memory but never releases it — even after the data is no longer needed. Over time, leaked memory accumulates, causing the app to slow down, consume excessive RAM, and eventually crash the browser tab.

In JavaScript, the garbage collector automatically frees memory that's no longer reachable. Leaks occur when references to unused objects are accidentally kept alive — through forgotten event listeners, uncleaned timers, closures, or detached DOM nodes.

React introduces its own category of leaks: missing useEffect cleanup, state updates on unmounted components, and stale closures that hold onto old references. These are among the most common bugs in production React apps.

Why this matters

Memory leaks are silent killers. The app works fine for 5 minutes, then gets progressively slower. Users refresh the page to "fix" it. In interviews, knowing how to detect and fix leaks shows real production experience.

02

Memory Management in JavaScript

JavaScript uses automatic memory management via a garbage collector (GC). You don't manually allocate or free memory — the engine handles it. But you need to understand how it works to avoid leaks.

📦

Allocate

Memory is allocated when you create variables, objects, arrays, functions, or DOM nodes. Every new object takes heap memory.

⚙️

Use

Your code reads and writes to allocated memory. As long as a reference exists, the memory stays allocated.

🗑️

Release

The garbage collector frees memory that's no longer reachable from the root (global scope, call stack). No references = eligible for GC.

Reachability — The Core Concept

The GC uses a mark-and-sweep algorithm. Starting from root references (global object, call stack, closures), it marks every object that's reachable. Anything not marked is swept (freed). A leak happens when an object is no longer needed but is still reachable.

reachability.jsjavascript
// Memory is allocated
let user = { name: "Alice", data: new Array(1000000) };

// user is reachable → GC won't free it
console.log(user.name);

// Setting to null removes the reference
user = null;
// Now the object is unreachable → GC will free it

// ❌ LEAK: if something else still references it
let cache = {};
cache.user = { name: "Alice", data: new Array(1000000) };
// Even if you don't need it anymore, cache.user keeps it alive
// Fix: delete cache.user; or cache = {};

The mental model

Think of memory like a room. Objects are furniture. The GC removes furniture that nobody can reach (no path from the door). A leak is furniture hidden behind other furniture — you forgot about it, but there's still a path to it, so the GC can't remove it.

03

What is a Memory Leak?

A memory leak is memory that was allocated, is no longer needed by the application, but cannot be freed by the garbage collector because a reference to it still exists somewhere.

Memory Over Timetext
Memory Usage

  │                                          ╱ ← Leak: keeps growing
  │                                     ╱╱╱╱
  │                                ╱╱╱╱╱
  │                           ╱╱╱╱╱
  │      ╱╲    ╱╲    ╱╲  ╱╱╱╱
  │  ╱╲╱╱  ╲╱╱  ╲╱╱  ╲╱╱        ← Normal: sawtooth (GC reclaims)
  │╱╱
  └──────────────────────────────────────────► Time

Normal: Memory goes up (allocations) and down (GC). Sawtooth pattern.
Leak:   Memory only goes up. GC can't reclaim leaked objects.

Real-World Impact

  • App gets progressively slower as GC runs more frequently on a larger heap
  • Browser tab memory grows from 50MB to 500MB+ over extended use
  • UI becomes janky — GC pauses block the main thread
  • Eventually the tab crashes with 'Out of Memory' error
  • Especially bad in SPAs where users don't refresh for hours (dashboards, chat apps)
04

Common Causes of Memory Leaks

These are the five most common causes of memory leaks in JavaScript applications. Each one follows the same pattern: a reference is created but never removed.

A. Unremoved Event Listeners

Every addEventListener creates a reference from the DOM element to your callback function (and everything in its closure). If you don't call removeEventListener, the callback and its closure stay in memory forever.

event-listener-leak.jsjavascript
// ❌ LEAK: listener is never removed
function setupTracker() {
  const data = new Array(100000).fill("tracking");

  window.addEventListener("scroll", () => {
    // This closure holds a reference to 'data'
    console.log(data.length);
  });
}
setupTracker();
// Even after setupTracker returns, the scroll listener
// keeps 'data' alive forever. Called on every navigation
// in an SPA = listeners pile up.

// ✅ FIX: Store reference and remove on cleanup
function setupTracker() {
  const data = new Array(100000).fill("tracking");

  const handler = () => console.log(data.length);
  window.addEventListener("scroll", handler);

  // Return cleanup function
  return () => window.removeEventListener("scroll", handler);
}

B. Timers Not Cleared

timer-leak.jsjavascript
// ❌ LEAK: interval runs forever, holds reference to heavyData
const heavyData = fetchLargeDataset();

setInterval(() => {
  updateDashboard(heavyData);
}, 1000);
// If the component/page is destroyed, the interval keeps running
// and heavyData is never freed.

// ✅ FIX: Clear the interval on cleanup
const heavyData = fetchLargeDataset();
const intervalId = setInterval(() => {
  updateDashboard(heavyData);
}, 1000);

// On cleanup (component unmount, page leave):
clearInterval(intervalId);

C. Closures Holding References

closure-leak.jsjavascript
// ❌ LEAK: closure keeps 'hugeArray' alive
function createHandler() {
  const hugeArray = new Array(1000000).fill("data");

  return function handler() {
    // Even if handler only uses hugeArray.length,
    // the entire array is kept in memory by the closure
    return hugeArray.length;
  };
}
const leak = createHandler();
// 'hugeArray' lives as long as 'leak' exists

// ✅ FIX: Extract only what you need
function createHandler() {
  const hugeArray = new Array(1000000).fill("data");
  const length = hugeArray.length; // Extract the value

  return function handler() {
    return length; // Closure only holds a number, not the array
  };
  // hugeArray can now be GC'd after createHandler returns
}

D. Detached DOM Nodes

detached-dom-leak.jsjavascript
// ❌ LEAK: DOM node removed from tree but still referenced
const elements = [];

function addItem() {
  const div = document.createElement("div");
  div.textContent = "Item " + elements.length;
  document.body.appendChild(div);
  elements.push(div); // JS reference kept
}

function removeItem() {
  const div = elements[0];
  document.body.removeChild(div); // Removed from DOM
  // But 'elements' array still holds a reference!
  // The DOM node is "detached" — not in the tree, but not GC'd
}

// ✅ FIX: Remove the JS reference too
function removeItem() {
  const div = elements.shift(); // Remove from array
  document.body.removeChild(div); // Remove from DOM
  // Now the node can be garbage collected
}

E. Global Variables

global-leak.jsjavascript
// ❌ LEAK: global variables are never garbage collected
window.cache = {};

function processData(userId) {
  const data = fetchUserData(userId);
  window.cache[userId] = data; // Grows forever!
  // Every user's data stays in memory for the entire session
}

// ✅ FIX: Use a bounded cache (LRU) or WeakMap
const cache = new Map();
const MAX_CACHE = 100;

function processData(userId) {
  const data = fetchUserData(userId);
  cache.set(userId, data);

  // Evict oldest entries when cache is full
  if (cache.size > MAX_CACHE) {
    const oldest = cache.keys().next().value;
    cache.delete(oldest);
  }
}
05

Memory Leaks in React

React's component lifecycle introduces specific patterns that cause memory leaks. The most common: side effects that aren't cleaned up when a component unmounts.

A. useEffect Without Cleanup

This is the #1 React memory leak. If your effect sets up a subscription, timer, or event listener, you must return a cleanup function.

useEffect-leak.tsxtypescript
// ❌ LEAK: Event listener is never removed
function ScrollTracker() {
  const [scrollY, setScrollY] = useState(0);

  useEffect(() => {
    const handler = () => setScrollY(window.scrollY);
    window.addEventListener("scroll", handler);
    // No cleanup! If this component unmounts and remounts,
    // listeners pile up. Each holds a reference to state.
  }, []);

  return <div>Scroll: {scrollY}px</div>;
}

// ✅ FIX: Return cleanup function
function ScrollTracker() {
  const [scrollY, setScrollY] = useState(0);

  useEffect(() => {
    const handler = () => setScrollY(window.scrollY);
    window.addEventListener("scroll", handler);

    return () => {
      window.removeEventListener("scroll", handler);
    };
  }, []);

  return <div>Scroll: {scrollY}px</div>;
}

B. Subscriptions Not Removed

subscription-leak.tsxtypescript
// ❌ LEAK: WebSocket stays open after unmount
function LiveFeed() {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    const ws = new WebSocket("wss://api.example.com/feed");
    ws.onmessage = (event) => {
      setMessages(prev => [...prev, JSON.parse(event.data)]);
    };
    // WebSocket stays open forever!
  }, []);

  return <div>{messages.map(m => <p key={m.id}>{m.text}</p>)}</div>;
}

// ✅ FIX: Close WebSocket on cleanup
function LiveFeed() {
  const [messages, setMessages] = useState([]);

  useEffect(() => {
    const ws = new WebSocket("wss://api.example.com/feed");
    ws.onmessage = (event) => {
      setMessages(prev => [...prev, JSON.parse(event.data)]);
    };

    return () => {
      ws.close(); // Clean up!
    };
  }, []);

  return <div>{messages.map(m => <p key={m.id}>{m.text}</p>)}</div>;
}

C. State Updates on Unmounted Components

unmounted-state-update.tsxtypescript
// ❌ LEAK: Async call updates state after unmount
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        // If component unmounted before fetch completes,
        // this tries to update state on a dead component
        setUser(data);
      });
  }, [userId]);

  return <div>{user?.name}</div>;
}

// ✅ FIX: Use AbortController to cancel the fetch
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const controller = new AbortController();

    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then(res => res.json())
      .then(data => setUser(data))
      .catch(err => {
        if (err.name !== "AbortError") throw err;
        // Fetch was cancelled — component unmounted, ignore
      });

    return () => controller.abort();
  }, [userId]);

  return <div>{user?.name}</div>;
}

D. Stale Closures

stale-closure.tsxtypescript
// ❌ BUG: Stale closure captures initial count value
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      // This closure captures count = 0 forever
      // because [] deps means it never re-runs
      setCount(count + 1); // Always sets to 1!
    }, 1000);

    return () => clearInterval(id);
  }, []); // ← Missing 'count' dependency

  return <div>{count}</div>;
}

// ✅ FIX: Use functional updater (no dependency needed)
function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(prev => prev + 1); // Always uses latest value
    }, 1000);

    return () => clearInterval(id);
  }, []); // Safe — no external dependencies

  return <div>{count}</div>;
}

The React cleanup rule

If your useEffect sets up anything that persists beyond the render (listener, timer, subscription, WebSocket, AbortController), it must return a cleanup function that tears it down. No exceptions.

06

Code Examples (Bad vs Good)

Side-by-side comparisons of leaky code and their fixes. These patterns cover the most common real-world scenarios.

Example 1: Event Listener in React

❌ Leaky Versiontypescript
function ResizeWatcher() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    // Adds a new listener every time component mounts
    // Never removed — leaks on every route change
    window.addEventListener("resize", () => {
      setWidth(window.innerWidth);
    });
  }, []);

  return <div>Width: {width}</div>;
}
✅ Fixed Versiontypescript
function ResizeWatcher() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    const handler = () => setWidth(window.innerWidth);
    window.addEventListener("resize", handler);
    return () => window.removeEventListener("resize", handler);
  }, []);

  return <div>Width: {width}</div>;
}

Example 2: setInterval in React

❌ Leaky Versiontypescript
function Clock() {
  const [time, setTime] = useState(new Date());

  useEffect(() => {
    // Interval runs forever — even after unmount
    setInterval(() => {
      setTime(new Date());
    }, 1000);
  }, []);

  return <div>{time.toLocaleTimeString()}</div>;
}
✅ Fixed Versiontypescript
function Clock() {
  const [time, setTime] = useState(new Date());

  useEffect(() => {
    const id = setInterval(() => {
      setTime(new Date());
    }, 1000);

    return () => clearInterval(id); // Clean up!
  }, []);

  return <div>{time.toLocaleTimeString()}</div>;
}

Example 3: Fetch with Cleanup

❌ Leaky Versiontypescript
function SearchResults({ query }: { query: string }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    // If query changes rapidly (typing), multiple fetches
    // race and the last one to resolve wins — could be stale
    fetch(`/api/search?q=${query}`)
      .then(r => r.json())
      .then(data => setResults(data));
  }, [query]);

  return <ul>{results.map(r => <li key={r.id}>{r.title}</li>)}</ul>;
}
✅ Fixed Versiontypescript
function SearchResults({ query }: { query: string }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    const controller = new AbortController();

    fetch(`/api/search?q=${query}`, { signal: controller.signal })
      .then(r => r.json())
      .then(data => setResults(data))
      .catch(err => {
        if (err.name !== "AbortError") throw err;
      });

    return () => controller.abort(); // Cancel previous fetch
  }, [query]);

  return <ul>{results.map(r => <li key={r.id}>{r.title}</li>)}</ul>;
}

The pattern

Every leaky example is missing the same thing: a cleanup function returned from useEffect. The fix is always the same shape: store a reference to the side effect, return a function that tears it down.

07

Detecting Memory Leaks (DevTools)

Chrome DevTools Memory tab gives you the tools to detect, measure, and trace memory leaks. Here's how to use each one.

Tool 1: Heap Snapshots

A heap snapshot captures every object in memory at a point in time. Take two snapshots and compare them to find objects that were allocated but never freed.

1

Open DevTools → Memory tab

Select 'Heap snapshot' as the profiling type.

2

Take Snapshot 1 (baseline)

Click 'Take snapshot' on a clean page load. This is your baseline — the normal memory state.

3

Perform the suspected leaky action

Navigate to a route and back, open/close a modal 10 times, or scroll through a list. Repeat the action multiple times to amplify the leak.

4

Force garbage collection

Click the trash can icon (🗑️) in the Memory tab to force GC. This ensures only truly leaked objects remain.

5

Take Snapshot 2

Click 'Take snapshot' again. Now compare the two snapshots.

6

Compare snapshots

Select Snapshot 2, change the view to 'Comparison' and select Snapshot 1. Look for objects with high 'Delta' (new objects that weren't freed). Sort by 'Retained Size' to find the biggest leaks.

Tool 2: Allocation Timeline

Select "Allocation instrumentation on timeline" and record while performing actions. Blue bars show allocations that are still alive. Gray bars show allocations that were freed. If blue bars keep growing, you have a leak.

What to Look For

IndicatorWhat It MeansAction
Growing JS HeapMemory keeps increasing without dropsTake heap snapshots, compare deltas
Detached DOM nodesDOM elements removed from tree but still in memorySearch 'Detached' in heap snapshot
Large Retained SizeAn object is keeping a large tree of objects aliveCheck retainer path to find the root reference
Growing event listener countListeners piling up on window/documentUse getEventListeners(window) in Console

The comparison workflow

Snapshot 1 → Action → GC → Snapshot 2 → Compare. If the delta shows objects that shouldn't exist (detached nodes, old component state, event handlers), you've found your leak. Click the object to see its "Retainers" — the chain of references keeping it alive.

08

Real Example Walkthrough

Let's walk through debugging a real memory leak in a React SPA — the kind of issue you'd encounter in production.

Scenario: Dashboard Gets Slower Over Time

Users report that a dashboard app becomes sluggish after 30 minutes of use. They have to refresh the page to "fix" it. The app has a sidebar with multiple views that users switch between frequently.

Step 1: Reproduce & Measure

Memory Tab Observationstext
Action: Switch between DashboardAnalyticsSettingsDashboard
        Repeat 10 times.

Heap Snapshot 1 (before): 12MB
Heap Snapshot 2 (after):  45MB  ← 33MB leaked!

After forcing GC: still 45MB. Memory is not being freed.

Comparison view shows:
  +300 Detached HTMLDivElement nodes
  +10  EventListener objects (scroll handlers)
  +10  WebSocket objects (not closed)

Step 2: Find the Leaky Component

❌ The Leaky Analytics Componenttypescript
function AnalyticsView() {
  const [data, setData] = useState([]);
  const chartRef = useRef(null);

  useEffect(() => {
    // LEAK 1: WebSocket never closed
    const ws = new WebSocket("wss://api.example.com/analytics");
    ws.onmessage = (e) => setData(JSON.parse(e.data));

    // LEAK 2: Scroll listener never removed
    window.addEventListener("scroll", handleScroll);

    // LEAK 3: Chart library instance never destroyed
    const chart = new HeavyChartLibrary(chartRef.current);
    chart.render(data);

    // No cleanup function returned!
  }, []);

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

Step 3: Fix All Three Leaks

✅ Fixed Analytics Componenttypescript
function AnalyticsView() {
  const [data, setData] = useState([]);
  const chartRef = useRef(null);

  useEffect(() => {
    const ws = new WebSocket("wss://api.example.com/analytics");
    ws.onmessage = (e) => setData(JSON.parse(e.data));

    const scrollHandler = () => handleScroll();
    window.addEventListener("scroll", scrollHandler);

    const chart = new HeavyChartLibrary(chartRef.current);
    chart.render(data);

    // ✅ Clean up EVERYTHING
    return () => {
      ws.close();                                    // Close WebSocket
      window.removeEventListener("scroll", scrollHandler); // Remove listener
      chart.destroy();                               // Destroy chart instance
    };
  }, []);

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

Step 4: Verify the Fix

After Fixtext
Same action: Switch views 10 times.

Heap Snapshot 1 (before): 12MB
Heap Snapshot 2 (after):  14MBNormal growth (2MB)
After forcing GC:         12MBBack to baseline

Comparison view:
  +0 Detached DOM nodes
  +0 Leaked event listeners
  +0 Leaked WebSocket objects

The debugging process

Reproduce → Snapshot → Action → GC → Snapshot → Compare → Find retainers → Fix cleanup → Verify. This systematic approach works for any memory leak.

09

Performance Impact

Memory leaks don't just waste RAM — they cause cascading performance problems that get worse over time.

🗑️

More Frequent GC

As the heap grows, the garbage collector runs more often and takes longer. Each GC pause blocks the main thread, causing UI jank and dropped frames.

🐢

Slower Operations

Larger heaps mean slower object allocation, slower property lookups, and slower iteration. Everything gets proportionally slower as memory grows.

💥

Tab Crashes

Chrome limits tab memory (typically 1-4GB). When the limit is hit, the tab crashes with 'Aw, Snap!' — losing all user state and work.

💻

System-Wide Impact

Leaked memory affects the entire system. Other apps slow down, the OS starts swapping to disk, and the user's machine becomes unresponsive.

Heap SizeGC PauseUser Experience
50MB (normal)~5msSmooth, no noticeable pauses
200MB (leaking)~20-50msOccasional jank, dropped frames
500MB+ (severe)~100-200msVisible freezes, unresponsive UI
1GB+ (critical)~500ms+Tab crash imminent, system slowdown
10

Prevention Techniques

The best memory leak is one that never happens. Follow these practices to prevent leaks from entering your codebase.

✓ Done

Always Return Cleanup from useEffect

If your effect creates a listener, timer, subscription, or AbortController — return a function that cleans it up. Make this a habit for every useEffect you write.

✓ Done

Use AbortController for Fetch

Pass an AbortController signal to every fetch call. Abort it in the cleanup function. This prevents state updates on unmounted components and cancels unnecessary network requests.

✓ Done

Clear All Timers

Store the return value of setTimeout/setInterval and call clearTimeout/clearInterval in cleanup. Every timer must have a corresponding clear.

→ Could add

Use WeakRef and WeakMap

WeakMap and WeakSet hold 'weak' references that don't prevent GC. Use them for caches where you want entries to be automatically cleaned up when the key is no longer referenced elsewhere.

✓ Done

Avoid Global State Accumulation

Don't store unbounded data in global variables, module-level caches, or window properties. Use bounded caches (LRU) or scope data to component lifecycle.

→ Could add

Destroy Third-Party Library Instances

Chart libraries, map instances, rich text editors — they allocate internal state. Call their destroy/dispose method in useEffect cleanup. Check the library docs for cleanup APIs.

The Complete useEffect Patterntypescript
useEffect(() => {
  // 1. Set up side effects
  const controller = new AbortController();
  const ws = new WebSocket(url);
  const timerId = setInterval(poll, 5000);
  window.addEventListener("resize", handler);

  // 2. Start async work
  fetchData({ signal: controller.signal });

  // 3. Return cleanup that tears down EVERYTHING
  return () => {
    controller.abort();
    ws.close();
    clearInterval(timerId);
    window.removeEventListener("resize", handler);
  };
}, [dependencies]);
11

Common Mistakes

These mistakes cause the majority of memory leaks in React applications.

🧹

Forgetting useEffect cleanup

The most common React leak. Adding an event listener or timer in useEffect without returning a cleanup function. Every mount adds another listener, and they're never removed.

Always return a cleanup function from useEffect if you set up any side effect. Lint rule: react-hooks/exhaustive-deps catches some of these.

🔄

Ignoring async race conditions

Multiple rapid state changes (typing in search, fast navigation) trigger multiple fetches. Older fetches resolve after newer ones, causing stale data and leaked promises.

Use AbortController to cancel previous fetches. Or use a flag (let cancelled = false) and check it before setState.

📌

Storing references in module scope

Storing component instances, DOM refs, or large data in module-level variables (outside components). These persist for the entire app lifetime and are never cleaned up.

Keep state inside components (useState, useRef). If you need module-level cache, use WeakMap or bounded Map with eviction.

🔗

Closures capturing large objects

Event handlers and callbacks in useEffect closures capture everything in scope. If a large array or object is in scope, the closure keeps it alive even if only a small value is needed.

Extract only the values you need before creating the closure. Or use useRef to hold mutable values without creating new closures.

📊

Not destroying third-party instances

Chart.js, Mapbox, Monaco Editor, and similar libraries allocate internal state, canvas contexts, and event listeners. Not calling their destroy method leaks all of it.

Check the library docs for destroy/dispose/cleanup methods. Call them in useEffect cleanup.

🌐

Adding listeners to window/document without removal

window and document persist for the entire page lifetime. Listeners added to them are never automatically cleaned up — they survive component unmounts.

Always pair addEventListener with removeEventListener in cleanup. Use the same function reference (not an anonymous function).

12

Interview Questions

Memory leak questions test your understanding of JavaScript internals and real-world debugging experience.

Q:What is a memory leak in JavaScript?

A: A memory leak occurs when memory is allocated but never freed because a reference to it still exists, even though the application no longer needs it. The garbage collector can't reclaim it because it's still 'reachable.' Over time, leaked memory accumulates, causing the app to slow down and eventually crash.

Q:How does JavaScript garbage collection work?

A: JavaScript uses a mark-and-sweep algorithm. Starting from root references (global scope, call stack, closures), the GC marks every reachable object. Any object not marked is considered unreachable and its memory is freed. Leaks happen when objects are no longer needed but are still reachable through some reference chain.

Q:How do you detect memory leaks using DevTools?

A: Open the Memory tab, take a heap snapshot (baseline), perform the suspected leaky action multiple times, force GC, take another snapshot, then compare. Look for growing object counts (Delta column), detached DOM nodes, and large retained sizes. The 'Retainers' panel shows the reference chain keeping leaked objects alive.

Q:What are the most common memory leaks in React?

A: (1) useEffect without cleanup — event listeners, timers, and subscriptions not cleaned up on unmount. (2) State updates on unmounted components — async operations (fetch, setTimeout) that call setState after the component is gone. (3) Stale closures — useEffect callbacks capturing old state values. (4) Not destroying third-party library instances (charts, maps, editors).

Q:How do you prevent memory leaks in useEffect?

A: Always return a cleanup function that tears down every side effect: removeEventListener for listeners, clearInterval/clearTimeout for timers, ws.close() for WebSockets, controller.abort() for fetch requests, and instance.destroy() for third-party libraries. The cleanup runs when the component unmounts or before the effect re-runs.

Q:What are detached DOM nodes and why do they leak?

A: A detached DOM node is an element that's been removed from the document tree (removeChild) but is still referenced by JavaScript (stored in a variable, array, or Map). The GC can't free it because it's still reachable. In DevTools, search for 'Detached' in a heap snapshot to find them. Fix by nullifying the JS reference when removing from DOM.

Q:What is the difference between WeakMap and Map for caching?

A: Map holds strong references to keys — entries stay in memory as long as the Map exists, even if nothing else references the key. WeakMap holds weak references — if the key object is no longer referenced elsewhere, the entry is automatically garbage collected. Use WeakMap for caches where entries should be cleaned up when the key is no longer needed.

Q:How would you debug a React app that gets slower over time?

A: (1) Open Memory tab, take baseline snapshot. (2) Use the app normally for a few minutes (navigate between routes, open/close modals). (3) Force GC, take another snapshot. (4) Compare — look for growing detached nodes, event listeners, and component state. (5) Check the retainer path to find which component is leaking. (6) Add missing useEffect cleanup. (7) Verify with another snapshot comparison.

13

Practice Section

Apply your knowledge to these real-world debugging scenarios.

1

Growing Memory

A chat app's memory grows from 50MB to 400MB after 2 hours of use. Users don't refresh the page. The app uses WebSocket for real-time messages and stores all messages in state. How do you debug and fix this?

Answer: Debug: Take heap snapshots at 0min and 30min, compare. Likely findings: (1) All messages stored in state forever — the array grows unbounded. (2) WebSocket onmessage handler may have stale closures. Fix: (1) Limit stored messages (keep last 200, virtualize the list). (2) Use a bounded data structure or paginate older messages from the server. (3) Ensure WebSocket cleanup on unmount. (4) Consider using a ref for the message list if you don't need re-renders on every message.

2

State Update After Unmount

You see this warning: "Can't perform a React state update on an unmounted component." It happens when navigating away from a page that fetches data. What's causing it and how do you fix it?

Answer: Cause: An async operation (fetch, setTimeout) started in useEffect resolves after the component unmounts, and the .then() callback calls setState on the now-dead component. Fix: Use AbortController — pass its signal to fetch() and call controller.abort() in the useEffect cleanup. For setTimeout, store the ID and call clearTimeout in cleanup. The AbortController approach is cleanest because it actually cancels the network request, saving bandwidth too.

3

Find the Leak

This component leaks memory. Find all the issues: function Dashboard() { const [data, setData] = useState([]); useEffect(() => { const ws = new WebSocket(url); ws.onmessage = (e) => setData(JSON.parse(e.data)); window.addEventListener("resize", handleResize); const id = setInterval(fetchMetrics, 5000); }, []); return <Chart data={data} />; }

Answer: Three leaks: (1) WebSocket is never closed — ws.close() missing from cleanup. (2) Resize listener is never removed — removeEventListener missing. (3) Interval is never cleared — clearInterval(id) missing. Fix: return a cleanup function that calls ws.close(), window.removeEventListener('resize', handleResize), and clearInterval(id). Additionally, if Chart is a third-party library, its instance should be destroyed in cleanup too.

14

Cheat Sheet (Quick Revision)

One-screen reference for memory leak debugging and prevention.

Quick Revision Cheat Sheet

Memory leak: Memory allocated but never freed because a reference still exists.

GC algorithm: Mark-and-sweep. Marks reachable objects from roots, sweeps the rest.

Common causes: Event listeners, timers, closures, detached DOM nodes, global variables.

React #1 leak: useEffect without cleanup. Always return a teardown function.

Event listeners: addEventListener → must have matching removeEventListener in cleanup.

Timers: setInterval/setTimeout → must have clearInterval/clearTimeout in cleanup.

Fetch cleanup: Use AbortController. Pass signal to fetch, abort in cleanup.

WebSocket: ws = new WebSocket(url) → ws.close() in cleanup.

Stale closures: Use functional updater: setCount(prev => prev + 1) instead of setCount(count + 1).

Detached DOM: Removed from tree but JS still references it. Nullify the reference.

Detection: Memory tab → Snapshot 1 → Action → GC → Snapshot 2 → Compare deltas.

What to look for: Growing heap, detached nodes, increasing listener count, large retained size.

WeakMap: Weak references for caching. Entries auto-GC'd when key is unreferenced.

Third-party libs: Charts, maps, editors — call destroy/dispose in useEffect cleanup.

Normal memory: Sawtooth pattern (up and down). Leak = only goes up.