Browser Memory Leak Detection

TL;DR / Memory leaks in browsers occur when objects that should be garbage collected remain referenced; DevTools heap snapshots and allocation timelines are the primary tools for identifying the retaining paths.

How It Works

 ┌─────────────────┐          ┌─────────────┐          ┌─────────────────┐
 │ Heap Snapshot 1 │─────────→│ User Action │─────────→│ Heap Snapshot 2 │
 └─────────────────┘          └─────────────┘          └─────────────────┘
                                     │
                                     │
                                     │
                                     ↓
                       ┌───────────────────────────┐
                       │      Comparison View      │
                       └───────────────────────────┘
                                     │
                          ┌──────────└────────────┐
                          │                       │
                          ↓                       ↓
                 ┌────────────────┐        ┌────────────┐
                 │ Retainers Tree │        │ Allocation │
                 └────────────────┘        └────────────┘

                  Find root cause

Edit diagram

A memory leak in browser JavaScript is not about memory being "lost" -- it is about objects remaining reachable from GC roots (window, active closures, event listeners, timers) when they should have been dereferenced. The garbage collector cannot reclaim anything still reachable, regardless of whether the application will ever use it again.

The three-snapshot technique is the most reliable detection method. Take heap snapshot 1 (baseline). Perform the action suspected of leaking (open/close a modal, navigate between routes, add/remove items). Take heap snapshot 2. Repeat the same action. Take heap snapshot 3. Compare snapshots 2 and 3 -- objects allocated between snapshots 1 and 2 that are still alive in snapshot 3 are leak candidates. This approach filters out one-time allocations that appear as false positives in a two-snapshot comparison.

In Chrome DevTools, the Memory panel provides three tools. Heap snapshot captures the complete object graph at a point in time. Switch to "Comparison" view between two snapshots to see objects added (# Delta column). Sort by "Retained Size" to find the largest leaked objects. The Retainers panel shows the chain of references keeping an object alive -- follow this chain to find the root cause.

Allocation instrumentation on timeline records every allocation with a stack trace over a time period. Blue bars represent objects still alive at the end; gray bars were already collected. Filter to the blue bars during a specific time window to find allocations that outlived their expected scope. The stack trace shows exactly where the leaked object was created.

Allocation sampling is lighter-weight than the timeline, using statistical sampling rather than tracking every allocation. It is suitable for production profiling where the overhead of full instrumentation is unacceptable.

The most common leak patterns in modern frontend applications are:

Detached DOM nodes: Elements removed from the document but still referenced by JavaScript variables, closures, or Map/Set entries. In the heap snapshot, filter by "Detached" to find these. A React component that stores a ref to a DOM element in a module-level variable leaks the entire subtree when the component unmounts.

Event listener accumulation: Adding listeners in a recurring path (render cycle, interval callback) without removing them. Each listener holds a reference to its closure's scope, which may capture component state, DOM nodes, or large data structures. The fix is using AbortController signals for cleanup or WeakRef-based patterns.

Closure scope retention: When multiple closures share a scope, V8 creates a single scope object for all of them. If one closure references a variable, that variable stays in the shared scope object, keeping it alive for any other closure's lifetime too. A timer callback that references one variable from a scope where a sibling closure captured a large array keeps that array alive for the timer's lifetime.

Forgotten timers and observers: setInterval callbacks, MutationObserver instances, IntersectionObserver instances, and ResizeObserver instances that are not disconnected when their target is removed. These keep their callback closures (and everything those closures reference) alive indefinitely.

WeakRef and FinalizationRegistry are modern tools for leak-resistant patterns. WeakRef holds a reference that does not prevent garbage collection. FinalizationRegistry runs a callback when an object is collected, enabling cleanup of associated resources. However, these are not deterministic -- the GC runs on its own schedule -- so they are not substitutes for proper lifecycle management.

The performance.measureUserAgentSpecificMemory() API provides programmatic memory measurement in production, returning breakdowns of memory usage by same-origin URLs. It requires cross-origin isolation (Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers) and runs asynchronously, scheduled by the browser to minimize overhead.

Gotchas

  • Heap snapshots include internal V8 objects -- filter by "Constructor" to focus on your application's objects; the raw heap contains thousands of internal entries that are not leaks
  • Minified code makes retainer chains unreadable -- use source maps in DevTools to get meaningful function and variable names in the retainer tree
  • Not every growing memory graph is a leak -- caches, undo histories, and virtualized list buffers intentionally retain data; distinguish between intentional retention and accidental retention
  • console.log retains references -- logged objects remain reachable from the DevTools console; clear the console or avoid logging large objects during leak investigation
  • SPA route transitions are the #1 leak source -- components from previous routes that fail to clean up listeners, timers, or global state references accumulate over the session lifetime