FrontCore
Memory & Garbage Collection

Overview

How the browser allocates and reclaims memory, how to detect leaks in production, the six patterns that create detached DOM nodes, and how to use Chrome DevTools memory tooling to find and confirm fixes.

Overview

Memory & Garbage Collection

Memory leaks in browser applications are subtle — the page doesn't crash immediately, it just gets slower and slower until the tab is killed. JavaScript's garbage collector handles most memory management automatically, but it can only collect objects that have no live references. Understanding what holds references alive, and how to detect when they shouldn't be, is the core skill in this section.

The section starts with how the GC works, then how to find leaks, then the most common specific cause, then the tooling to investigate all of it.


What's Covered

Garbage Collection Timing — V8's generational architecture: young generation (semi-space scavenger, runs frequently, ~1–5ms), old generation (mark-sweep-compact, runs infrequently, 5–20ms in modern V8). Infant mortality hypothesis: most objects die young and never enter the old generation. Incremental marking breaks the marking phase into ~1ms increments interleaved with JS. Concurrent marking runs on background threads, requiring only a short final-remark stop-the-world. Oilpan: Chromium's separate C++ GC for DOM nodes, cooperating with V8 at their boundary. PerformanceObserver with type: 'gc' for production-safe GC timing measurement in Node.js. Avoiding closure-based retention across await boundaries: extract primitives before awaiting to allow large objects to stay in the young generation. Bounded cache patterns with TTL eviction vs unbounded module-level Map. FinalizationRegistry for post-collection hooks: callbacks are not deterministic, never use for critical cleanup, only as safety-net for external resource release.

Browser Memory Leak Detection — Five leak archetypes: forgotten event listeners (closure retains scope), detached DOM nodes, unbounded caches, uncleaned timers, closures over large objects. Mark-and-sweep reachability: a leak is an unintentional reference path from a GC root to the leaked object. WeakMap<HTMLElement, V> vs Map<HTMLElement, V>: WeakMap keys are not strong references, entries auto-released when element is GC'd, no manual .delete() needed. WeakRef for optional caches: .deref() returns the object or undefined if already collected — always handle undefined, never use as authoritative state. AbortController + { signal } in addEventListener for composable multi-listener cleanup with a single abort() call; also cancels fetch automatically. Three-snapshot method: baseline snapshot → perform action → force GC (🗑) → second snapshot → Comparison view sorted by +Delta → Retainer tree to root cause. Production-build profiling requirement: StrictMode double-invokes effects, dev-mode React inflates object counts.

Detached DOM Nodes — Six creation patterns: (1) module-level variables referencing removed elements (activeModal = null fix); (2) event listener closures never removed — inline arrow functions make removal impossible, use AbortController with { signal }; (3) React useEffect without cleanup return — setInterval capturing ref.current continues after unmount; (4) observer callbacks outliving elements — ResizeObserver, IntersectionObserver, MutationObserver all hold internal target references, must call disconnect() in effect cleanup; (5) Map<HTMLElement, V> caches — strong key references prevent collection, replace with WeakMap<HTMLElement, V>; (6) subtree retention — a reference to any ancestor retains its entire DOM descendant tree. Finding detached nodes: heap snapshot → type "Detached" in class filter → sorts all Detached HTMLDivElement etc. Retained size vs shallow size: a single reference to a <section> with 1,000 children may show shallow size of 200B but retained size of 8MB. Retainer tree interpretation: follow upward from leaked node to GC root to identify the specific variable/closure/data structure holding it.

Memory Profiling with Chrome DevTools — Three tools comparison: Heap Snapshot (point-in-time, high overhead, JS pause, best for comparison), Allocation Timeline (live allocations, blue=retained/grey=freed bars, medium overhead), Allocation Sampling (flame chart of allocation-heavy call paths, very low overhead, safe for near-production). Four Heap Snapshot views: Summary (by constructor, inventory), Comparison (+Delta sorting after forced GC, leak confirmation), Containment (GC roots → object graph exploration), Dominators (single-point-of-retention objects, highest-leverage fix targets). Retainer tree reading: bottom entry = GC root (window, module, active closure), working upward reveals the exact property/variable/listener holding the leaked object. Forced GC before snapshotting: skipping this includes pending-collection unreachable objects as false positives in Comparison. performance.measureUserAgentSpecificMemory(): measures total JS memory including workers and iframes (not just main-thread heap), requires cross-origin isolation (COOP: same-origin + COEP: require-corp). Debugging workflow: two snapshots + Comparison → sort retained size → Retainer tree → fix → third snapshot to confirm Delta returns to zero.

On this page