DEV Community

Chak Shun Yu
Chak Shun Yu

Posted on • Originally published at chakshunyu.com

How to Find Memory Leaks in React Components using JavaScript Heap Profiling

Your user's browser tab locks up completely after thirty minutes of interacting with your single-page application. The UI stutters during simple modal interactions, and eventually, the dreaded "Aw, Snap!" out-of-memory error suddenly terminates the entire session. If you want to find memory leaks in React components, you cannot rely on reading code.

The problem typically compounds under the surface. A rogue event attachment or a long-lived closure prevents the V8 garbage collector from sweeping away heavy DOM nodes. Modern frameworks abstract away raw DOM manipulation, providing velocity but masking the underlying memory allocations. You unmount a complex data grid, expecting the browser to reclaim the memory, but a single stray reference traps the entire component tree in the heap.

We see this most frequently in enterprise dashboards and long-lived session applications where users never hard-refresh the page. To fix this, we must stop guessing at our useEffect cleanup blocks and start proving the leak exists using browser tooling.

The JavaScript Heap Profile Tutorial

When isolated closures dodge our static analysis, we turn to Chrome DevTools for a forensic view into what objects persist across garbage collection cycles. We use this JavaScript heap profiling methodology to pinpoint exact leak sources.

  1. Record a Baseline Snapshot: Open Chrome DevTools, navigate to the Memory tab, select "Heap snapshot", and click "Take snapshot". This establishes our baseline memory consumption before any interaction occurs.
  2. Trigger the Suspected Action: Perform the exact UI action that causes the suspected leak. Open the heavy modal, navigate to a different route, or trigger the data fetch, then return the application to its original state.
  3. Force Garbage Collection: Click the trash can icon in the Memory tab to explicitly run the garbage collector. This clears out normal temporary objects, leaving only genuine memory leaks behind.
  4. Take a Second Snapshot: Click "Take snapshot" again to capture the current state of the heap after the action and cleanup.
  5. Compare the Snapshots: Change the view dropdown above the table from "Summary" to "Comparison", comparing the second snapshot against the first.

Sort the comparison view by Delta to see which specific objects multiplied during the interaction. You want to look for objects labeled Detached HTMLElement or specific React fiber nodes. A detached HTML element represents a DOM node that is no longer attached to the document tree but that a JavaScript reference still retains.

Chrome DevTools Memory tab Heap Snapshot in Comparison view showing detached nodes

Tracing the Retaining Path

When you find a leaked Detached HTMLElement, inspect its retaining path in the lower panel. Chrome shows you the exact chain of references keeping that object alive. You will often see closure or event listener pointing back to a specific line in your React component.

Chrome DevTools Retainers panel showing the handleScroll closure retaining path
Understanding the difference between Shallow Size (memory held by the object itself) and Retained Size (memory freed if the object is deleted) helps prioritize which leaks to fix first. For more details on interpreting this data, check the Chrome DevTools Memory Documentation.

Once you trace the retaining path, you typically uncover an event listener or observer that failed to detach. If we attach global event listeners to the window or document inside a component without explicitly removing them, the closure retains the entire component scope. Modern APIs like IntersectionObserver or ResizeObserver are particularly dangerous because they hold strong references to DOM elements.

Resolving the Detached Nodes

Once we identify the memory leak in the profiler, the direct resolution requires rigorous cleanup. We refactor the rogue scroll listener that the heap snapshot flagged, ensuring the garbage collector reclaims the memory once the component unmounts. Notice how we implement the cleanup return function within the useEffect hook and stabilize the listener reference using useCallback to prevent reference drift. For broader context on hook dependencies and identity, compare this to how we handle memoization in a deep dive comparison between useMemo and useCallback.

import { useState, useEffect, useCallback } from 'react';

export function ScrollTracker(): JSX.Element {
  const [scrollPosition, setScrollPosition] = useState<number>(0);

  const handleScroll = useCallback((): void => {
    setScrollPosition(window.scrollY);
  }, []);

  useEffect((): (() => void) => {
    // Attach the listener to the global window object
    window.addEventListener('scroll', handleScroll);

    // Return the cleanup function to remove the listener on unmount
    return (): void => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, [handleScroll]);

  return <div>Current scroll: {scrollPosition}px</div>;
}
Enter fullscreen mode Exit fullscreen mode
  • Explicit Detachment: Return a cleanup function on unmount to sever references from the global window object, preventing memory accumulation that slows down long user sessions.
  • Dependency Stability: Wrap the handler in useCallback to ensure the identical reference passes to removeEventListener, eliminating silent memory leaks that lead to browser tab crashes.
  • Garbage Collection Release: Sever the global reference to let the V8 engine sweep away the component's closure, state, and fiber nodes, protecting application responsiveness and Core Web Vitals.

By enforcing strict cleanup patterns and integrating continuous heap profiling into our standard development workflow, we eliminate silent crashes entirely. We build resilient applications that maintain responsive performance profiles, protecting the user experience regardless of how long the tab remains open.

Top comments (0)