TechLead
Lesson 4 of 20
6 min read
DevTools & Productivity

Memory Profiling

Detect memory leaks, analyze heap snapshots, and understand garbage collection using Chrome DevTools Memory panel

Why Memory Profiling Matters

Memory leaks in JavaScript are insidious. Unlike a crash, a memory leak silently degrades performance over time. The page becomes sluggish, animations stutter, and eventually the browser tab may crash with an "Out of Memory" error. Memory profiling helps you find and fix these leaks before users notice.

Common Causes of Memory Leaks

  • Forgotten Event Listeners: Listeners attached to elements or global objects that are never removed
  • Detached DOM Nodes: DOM elements removed from the tree but still referenced by JavaScript
  • Closures: Functions that capture large scopes and keep references alive longer than needed
  • Global Variables: Accidentally storing data in global scope (window.data = bigArray)
  • Timers and Intervals: setInterval or setTimeout callbacks that reference objects, never cleared
  • Unsubscribed Observables: RxJS subscriptions, WebSocket connections, or event emitters not properly cleaned up

The Memory Panel

The Memory panel (Cmd + Option + I then click Memory) offers three profiling modes:

1. Heap Snapshot

Takes a snapshot of all objects in JavaScript memory at a single point in time. Shows object types, sizes, and reference chains. Best for finding detached DOM nodes and identifying what is keeping objects alive.

2. Allocation Instrumentation on Timeline

Records memory allocations over time as blue bars on a timeline. Blue bars that remain after garbage collection indicate potential leaks. This mode lets you correlate allocations with specific user actions.

3. Allocation Sampling

A lightweight, low-overhead profiler that samples memory allocations by function. Good for long-running sessions where Heap Snapshots would be too heavy. Shows you which functions allocate the most memory.

Taking and Analyzing Heap Snapshots

The most powerful technique for finding memory leaks is the "three snapshot" technique:

  1. Snapshot 1 (Baseline): Take a snapshot after the page loads and stabilizes. Force garbage collection first by clicking the trash can icon.
  2. Perform the Action: Do the action you suspect causes a leak (open a modal, navigate to a route and back, scroll a list).
  3. Snapshot 2 (After Action): Force GC and take another snapshot. Objects here should be mostly the same as Snapshot 1.
  4. Repeat the Action: Perform the same action again.
  5. Snapshot 3 (After Repeat): Force GC and take a final snapshot. Compare Snapshot 2 and 3 - if memory grew, you have a leak.

Reading Heap Snapshots

Switch to "Comparison" view and compare Snapshot 3 to Snapshot 2. The "Delta" column shows how many new objects of each type were allocated. Look for:

  • Detached HTMLDivElement, Detached HTMLSpanElement: DOM nodes removed from the tree but kept alive by JS references.
  • Growing arrays or Maps: Data structures that keep accumulating entries.
  • Closures: Anonymous functions holding references to large scopes.
  • (string), (array), (object): Growing primitive collections.
// Example: Memory leak from forgotten event listener
class SearchComponent {
  private handler: (e: Event) => void;

  mount() {
    // BAD: This handler is never removed
    this.handler = (e: Event) => {
      this.handleResize(e);
    };
    window.addEventListener('resize', this.handler);
  }

  unmount() {
    // MISSING: window.removeEventListener('resize', this.handler);
    // The component is "unmounted" but the resize handler still holds
    // a reference to 'this', keeping the entire component in memory
  }

  handleResize(e: Event) {
    console.log('resized', e);
  }
}

// FIXED: Always clean up event listeners
class SearchComponentFixed {
  private handler: (e: Event) => void;

  mount() {
    this.handler = (e: Event) => this.handleResize(e);
    window.addEventListener('resize', this.handler);
  }

  unmount() {
    window.removeEventListener('resize', this.handler);
  }

  handleResize(e: Event) {
    console.log('resized', e);
  }
}

Detecting Detached DOM Nodes

Detached DOM nodes are one of the most common memory leaks in web applications. A DOM node is "detached" when it has been removed from the document tree (via removeChild or React unmounting) but JavaScript still holds a reference to it.

// Example: Detached DOM node leak
let cachedElement: HTMLElement | null = null;

function showPopup() {
  const popup = document.createElement('div');
  popup.innerHTML = '<p>Large popup content...</p>';
  document.body.appendChild(popup);
  cachedElement = popup; // Reference stored!
}

function hidePopup() {
  if (cachedElement) {
    document.body.removeChild(cachedElement);
    // BUG: cachedElement still holds a reference
    // The DOM node cannot be garbage collected
  }
}

// FIXED: Null out the reference after removal
function hidePopupFixed() {
  if (cachedElement) {
    document.body.removeChild(cachedElement);
    cachedElement = null; // Allow GC
  }
}

To find detached nodes in a heap snapshot, type "Detached" in the filter box. All detached DOM trees will appear, and you can inspect their retaining tree to see what JavaScript variable is keeping them alive.

React-Specific Memory Leaks

// Common React memory leak: state update after unmount
function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    let cancelled = false;

    async function fetchUser() {
      const response = await fetch(`/api/users/${userId}`);
      const data = await response.json();
      if (!cancelled) {
        setUser(data); // Only update if still mounted
      }
    }

    fetchUser();

    return () => {
      cancelled = true; // Prevent state update after unmount
    };
  }, [userId]);

  // ...
}

// Common React leak: subscriptions in useEffect
function LiveDashboard() {
  const [data, setData] = useState([]);

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

    // CRITICAL: Close the WebSocket on cleanup
    return () => {
      ws.close();
    };
  }, []);

  // ...
}

Using WeakRef and FinalizationRegistry

// WeakRef: Hold a reference that doesn't prevent GC
const cache = new Map<string, WeakRef<object>>();

function getCachedObject(key: string): object | undefined {
  const ref = cache.get(key);
  if (ref) {
    const obj = ref.deref(); // Returns undefined if GC'd
    if (obj) return obj;
    cache.delete(key); // Clean up dead reference
  }
  return undefined;
}

function cacheObject(key: string, obj: object) {
  cache.set(key, new WeakRef(obj));
}

// FinalizationRegistry: Get notified when objects are GC'd
const registry = new FinalizationRegistry((key: string) => {
  console.log(`Object with key "${key}" was garbage collected`);
  cache.delete(key);
});

function cacheWithCleanup(key: string, obj: object) {
  cache.set(key, new WeakRef(obj));
  registry.register(obj, key);
}

Performance Monitor for Real-Time Memory

Open the Performance Monitor (Cmd + Shift + P, type "Performance Monitor") for a real-time dashboard showing:

  • JS Heap Size: Current JavaScript memory usage. Watch for a sawtooth pattern (normal GC) versus a steadily rising line (leak).
  • DOM Nodes: Total number of DOM nodes. Should stabilize after page load. A constantly rising count means nodes are being created but never removed.
  • JS Event Listeners: Total number of registered event listeners. Rising count without corresponding removal indicates listener leaks.
  • Documents: Number of document objects. Should be 1 for most apps. Multiple documents may indicate iframe or popup leaks.

Memory Debugging Checklist

  • 1. Open Performance Monitor and watch JS Heap Size during normal usage
  • 2. If memory rises steadily, use the three-snapshot technique to isolate the leak
  • 3. Filter heap snapshot for "Detached" to find orphaned DOM nodes
  • 4. Check retaining tree to find what JavaScript code holds the reference
  • 5. Fix the leak by removing event listeners, nulling references, or using WeakRef
  • 6. Verify by repeating the three-snapshot technique

Continue Learning