Browser Memory Leak Detection: Finding What the GC Cannot Collect

Performance

JavaScript has a garbage collector. Memory that is no longer reachable from any root in the object graph is automatically freed. A memory leak in JavaScript is not about forgetting to free() memory — it is about accidentally keeping references to objects you no longer need, preventing the garbage collector from freeing them.

In a browser application, this typically manifests as: open the app, navigate around, use features — and watch memory in the Task Manager climb steadily and never come back down.

The Common Sources

Detached DOM nodes: DOM elements that have been removed from the document but are still referenced in JavaScript:

// The array holds references to all the buttons ever created,
// even after they've been removed from the DOM
const buttons = []

function addButton() {
  const btn = document.createElement('button')
  document.body.appendChild(btn)
  buttons.push(btn) // ❌ this reference keeps the button alive after removal
}

function removeButton(btn) {
  btn.remove()
  // btn is still in the `buttons` array — it is now "detached"
}

Forgotten event listeners:

// Listeners added but never removed
function addComponentToPage() {
  window.addEventListener('resize', handleResize)
  // if handleResize or anything it closes over doesn't get cleaned up...
}

// If this component is removed from the page without calling:
// window.removeEventListener('resize', handleResize)
// the listener and its closure live forever

Closures retaining large objects:

function processData(largeArray) {
  const derived = computeSomething(largeArray)
  // largeArray is still reachable via the closure's scope chain
  return function() {
    return derived
  }
}

Timers that are never cleared:

// ❌ Interval runs forever, preventing the component from being GC'd
const intervalId = setInterval(() => {
  updateUI(componentRef)
}, 1000)
// componentRef is kept alive by the interval callback

// ✅ Clear in cleanup
return () => clearInterval(intervalId)

React-Specific Leaks

In React, the most common source of leaks is async operations that try to update component state after the component unmounts:

useEffect(() => {
  let mounted = true
  fetchData().then(data => {
    if (mounted) setState(data) // guard
  })
  return () => { mounted = false }
}, [])

Without the guard, if the component unmounts before the fetch resolves, setState would reference the component's fiber — keeping it alive. React 18 shows a console warning for this in development.

Global event listeners, subscriptions, and MutationObservers that are not disconnected in the cleanup function are also common sources.

Finding Leaks in DevTools

Memory Timeline

  1. Open Chrome DevTools → Memory tab
  2. Select "Allocation instrumentation on timeline"
  3. Run the workflow you suspect is leaking (navigate to a page, leave, navigate back)
  4. Take a heap snapshot at the beginning and after several cycles
  5. Look for allocations that grow but never decrease

Heap Snapshot Comparison

  1. Take a snapshot before the action
  2. Perform the action several times
  3. Force GC (garbage bin icon in DevTools Memory tab)
  4. Take another snapshot
  5. Change the view to "Comparison" and select the first snapshot

Objects shown in the delta that have a positive count and size are leaking.

The "Detached DOM trees" Filter

In the Heap Snapshot view, change the filter to "Detached DOM trees." Any DOM nodes shown here are removed from the document but still referenced somewhere in JavaScript. Clicking into the node shows the retaining path — the chain of references keeping it alive.

Detached HTMLButtonElement
  ← buttons[] in module scope
  ← (closure) in addButton

This retaining path tells you exactly where to fix the leak.

Fixing Common Patterns

Event listeners: always pair add with remove:

useEffect(() => {
  window.addEventListener('resize', handleResize)
  return () => window.removeEventListener('resize', handleResize)
}, [handleResize])

Subscriptions: always unsubscribe:

useEffect(() => {
  const subscription = store.subscribe(handleChange)
  return () => subscription.unsubscribe()
}, [])

WeakRef and WeakMap for optional retention:

// WeakMap — key is held weakly; if the element is GC'd, the entry is removed
const metadata = new WeakMap()
metadata.set(element, { data: 'some data' })
// when element is GC'd, the WeakMap entry disappears automatically

The GC Is Not Instant

A common mistake: you fix a leak, reload, take a snapshot, and memory still looks high. The garbage collector runs on its own schedule — not immediately when objects become unreachable. Click the garbage bin icon in DevTools to force a collection before comparing snapshots.

Conclusion

Memory leaks in the browser are always caused by unintended retention — references that outlive their intended scope. The three most common sources in modern frontend code are forgotten event listeners, detached DOM nodes, and async operations that hold references after component unmount. Chrome DevTools' Heap Snapshot with comparison mode and the detached DOM filter make these findable without guessing. The fix is always the same: identify the retaining chain and remove the unexpected reference.