FrontCore
Memory & Garbage Collection

Memory Leak Detection

How to identify, diagnose, and fix browser memory leaks — mark-and-sweep reachability, WeakMap vs Map for caches, WeakRef for optional references, the three-snapshot method, Allocation Timeline, AbortController for cleanup patterns, and the five leak archetypes.

Memory Leak Detection

Overview

A memory leak in a browser application occurs when your code allocates memory that is never released, even after it's no longer needed. Over time, leaked memory accumulates — the browser tab consumes more and more RAM, causing sluggish performance, janky animations, and eventually a crashed tab.

Memory leaks are especially common in single-page applications where components mount and unmount frequently, event listeners accumulate, and closures accidentally hold references to large objects.


How It Works

JavaScript uses a mark-and-sweep garbage collector. Starting from GC roots (window, active stack frames, module-level variables), the GC traverses every reachable reference and marks objects as live. Anything unmarked is swept and freed.

A leak means the GC is marking something as reachable when you don't intend it to be — something holds a reference to the object that should not exist.

Five Leak Archetypes

1. Forgotten event listeners. addEventListener without a corresponding removeEventListener. The listener closure keeps its captured variables alive indefinitely.

2. Detached DOM nodes. Elements removed from the document but still referenced in a JS variable, Map, cache, or useRef.

3. Unbounded caches. Module-level Map or Set that grows without eviction. Module scope is a GC root — entries live for the page's lifetime.

4. Uncleaned timers. setInterval callbacks that capture component state or DOM references, never cleared on unmount.

5. Closures over large objects. A closure inadvertently captures a large buffer, array, or parsed response in its scope — keeping it alive as long as the closure exists.


Code Examples

Leaked Event Listener — and the Fix

// app/components/ResizeTracker.tsx
"use client";

import { useEffect, useState } from "react";

export default function ResizeTracker() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);

    window.addEventListener("resize", handleResize);

    // ✅ Must return cleanup — without this, every mount adds a new listener
    // that is never removed, keeping handleResize (and setWidth) alive forever
    return () => {
      window.removeEventListener("resize", handleResize);
    };
  }, []);

  return <p>Window width: {width}px</p>;
}

Leaked setInterval — and the Fix

// app/components/LiveClock.tsx
"use client";

import { useEffect, useState } from "react";

export default function LiveClock() {
  const [time, setTime] = useState(new Date().toLocaleTimeString());

  useEffect(() => {
    const intervalId = setInterval(() => {
      setTime(new Date().toLocaleTimeString());
    }, 1000);

    // ✅ Clear interval on unmount — otherwise setTime keeps the component's
    // fiber alive in memory even after React has unmounted the component
    return () => clearInterval(intervalId);
  }, []);

  return <p>Current time: {time}</p>;
}

WeakMap for DOM-Keyed Caches

// lib/elementCache.ts

// ❌ Strong Map — DOM nodes stored as keys are never GC'd
// Even after removal from the document, the Map keeps them alive
const badCache = new Map<HTMLElement, { clickCount: number }>();

// ✅ WeakMap — entries are automatically released when the key node is GC'd
// No manual cleanup required; no memory profiling needed to find leaks here
const elementMetadata = new WeakMap<HTMLElement, { clickCount: number }>();

export function trackElement(el: HTMLElement) {
  if (!elementMetadata.has(el)) {
    elementMetadata.set(el, { clickCount: 0 });
  }

  el.addEventListener("click", () => {
    const meta = elementMetadata.get(el);
    if (meta) meta.clickCount++;
  });
}

// When `el` is removed from the DOM and no strong JS reference remains,
// both the WeakMap entry and the click count object are collected automatically

WeakMap keys must be objects (including DOM nodes). Values can be anything. Use WeakMap whenever you need to associate metadata with an object without preventing that object's collection.


WeakRef for Optional References

WeakRef wraps an object with a weak reference — the GC can still collect the target. Use .deref() to access it; returns undefined if already collected:

// lib/thumbnailCache.ts
// Cache thumbnail images weakly — large images can be reclaimed under memory pressure

const thumbnailCache = new Map<string, WeakRef<ImageBitmap>>();

export async function getThumbnail(url: string): Promise<ImageBitmap | null> {
  const cached = thumbnailCache.get(url);

  if (cached) {
    const bitmap = cached.deref();
    if (bitmap) return bitmap; // still alive — return it
    thumbnailCache.delete(url); // was collected — clean up the dead ref
  }

  // Not cached or was GC'd — fetch and cache freshly
  const response = await fetch(url);
  const blob = await response.blob();
  const newBitmap = await createImageBitmap(blob);

  thumbnailCache.set(url, new WeakRef(newBitmap));
  return newBitmap;
}

WeakRef is appropriate for caches where staleness is acceptable. Never use it for state that must be authoritative — deref() can return undefined at any time after creation. Always handle the undefined case.


AbortController for Cancellable Cleanup Patterns

AbortController provides a composable cleanup signal that works for fetch, event listeners, and custom cleanup:

// components/LiveFeed.tsx
"use client";

import { useEffect, useState } from "react";

export function LiveFeed({ feedId }: { feedId: string }) {
  const [events, setEvents] = useState<string[]>([]);

  useEffect(() => {
    const controller = new AbortController();
    const { signal } = controller;

    // Cancellable fetch — aborted automatically on unmount
    fetch(`/api/feeds/${feedId}`, { signal })
      .then((r) => r.json())
      .then((data) => {
        if (!signal.aborted) setEvents(data.events);
      })
      .catch((err) => {
        if (err.name !== "AbortError") throw err;
        // AbortError is expected on unmount — swallow it
      });

    // Event listener with AbortSignal — removed automatically when aborted
    window.addEventListener(
      "online",
      () => {
        if (!signal.aborted) setEvents((e) => [...e, "reconnecting"]);
      },
      { signal }, // ← browser removes this listener when signal is aborted
    );

    return () => controller.abort(); // single cleanup call handles everything
  }, [feedId]);

  return (
    <ul>
      {events.map((e, i) => (
        <li key={i}>{e}</li>
      ))}
    </ul>
  );
}

Detached DOM Node — and the Fix

// ❌ detachedButton is removed from DOM but the variable keeps it alive
let detachedButton: HTMLButtonElement | null =
  document.querySelector("#submit");
document.body.removeChild(detachedButton!);
// detachedButton still holds a strong reference — LEAK

// ✅ Null out the reference after removal
let button: HTMLButtonElement | null = document.querySelector("#submit");
document.body.removeChild(button!);
button = null; // GC can now collect the element and its entire subtree

Three-Snapshot Method (Chrome DevTools Workflow)

The most reliable way to confirm and locate a leak:

Step 1: Establish a stable baseline
  - Open DevTools → Memory → Heap Snapshot
  - Take Snapshot 1 (after initial page load and stable state)

Step 2: Perform the suspected leaking action multiple times
  - Example: open and close a modal 5 times
  - Navigate to a route and back 5 times

Step 3: Force garbage collection
  - Click the "Collect garbage" (🗑) icon in the Memory tab
  - This ensures the heap only shows genuinely retained objects

Step 4: Take Snapshot 2
  - Switch view to "Comparison" vs Snapshot 1
  - Sort by "+Delta" (net object count increase)
  - Objects with positive Delta after GC are leaked

Step 5: Drill into leaked objects
  - Click a suspicious class (e.g., "Closure", "HTMLDivElement")
  - Expand the "Retainers" panel at the bottom
  - The retainer tree shows the exact reference path
    keeping the object alive — follow it to find the root cause

Step 6: Fix and verify
  - Apply the fix, repeat steps 2–4
  - A fixed leak shows Delta approaching 0 for the previously leaking class

Real-World Use Case

A dashboard SPA where users switch between a live chart view (polling every 2 seconds) and a settings panel. After navigating back and forth, the tab slows noticeably.

Root cause: the chart's setInterval is never cleared — each time the chart mounts, a new interval starts. After 10 navigations, 10 intervals run simultaneously, each calling setInterval's callback which captures a reference to the chart canvas element and the state setter. The canvas is detached (component unmounted) but stays in memory, held by all 10 interval closures.

Fix: return () => clearInterval(id) from the chart's useEffect. Verify with two heap snapshots (before and after 5 navigations): the +Delta count for canvas-related objects drops to zero.


Common Mistakes / Gotchas

1. Assuming React cleans up subscriptions. React only calls your cleanup function if you return one from useEffect. External subscriptions (WebSockets, RxJS, SSE connections, third-party SDKs) must be manually unsubscribed in that return.

2. Inline arrow functions in addEventListener make cleanup impossible. removeEventListener requires the exact same function reference. el.removeEventListener("click", () => handler()) does nothing — the arrow function is a different reference each time.

3. Not forcing GC before taking heap snapshots. The heap fluctuates as V8 pre-allocates and defers collection. Always click "Collect garbage" before snapshotting to get a stable baseline.

4. Profiling in development mode. React StrictMode double-invokes effects and mounts/unmounts components, inflating memory. next dev includes source maps and development-only objects. Always confirm leaks against a production build.

5. Using WeakRef as a primary state store. deref() can return undefined at any GC cycle. WeakRef is appropriate for caches where staleness is acceptable; it's not appropriate for authoritative state.


Summary

Memory leaks occur when objects remain reachable to the GC despite being logically no longer needed. The five archetypes are: forgotten event listeners, detached DOM nodes, unbounded caches, uncleaned timers, and closures over large objects. Fix event listeners by returning cleanup from useEffect, or passing { signal } to addEventListener and aborting an AbortController on unmount. Replace Map<HTMLElement, ...> with WeakMap<HTMLElement, ...> so cache entries are automatically released when DOM nodes are collected. Use WeakRef for optional caches with deref() null-checks. Diagnose with the three-snapshot method in Chrome DevTools: take a baseline, perform the leaking action, force GC, take a second snapshot, compare with +Delta sorting, and follow the Retainers tree to the root cause. Always profile against a production build.


Interview Questions

Q1. How does JavaScript's mark-and-sweep GC determine what is a memory leak?

The GC starts from a set of "roots" — the global object (window), active call stack frames, and module-level variables — and traverses every reachable object reference, marking each reachable object as live. After traversal, any object not marked is unreachable and freed. A memory "leak" from the GC's perspective is not an error — the GC collected everything it should. A leak is when your intent is for an object to be unreachable (you logically "released" it) but a reference still exists that the GC follows during marking. The object is correctly kept alive by the GC; the bug is the unintentional reference. Finding leaks means finding these unintentional references — which is why the DevTools retainer tree is the key diagnostic tool.

Q2. What is the difference between WeakMap and Map for DOM-keyed caches, and why does it matter?

A Map holds strong references to its keys and values. If you use an HTMLElement as a Map key and that element is removed from the DOM, the Map itself keeps the element alive — it's reachable through the Map, which is reachable through the module-level variable holding the Map. The element cannot be GC'd. A WeakMap holds weak references to its keys — the key is not considered reachable through the WeakMap for GC purposes. If no other strong reference to the element exists after DOM removal, the GC can collect both the element and the WeakMap entry automatically. You don't need to manually delete entries from a WeakMap when elements are removed — cleanup is automatic. This eliminates an entire category of DOM-related memory leaks in element-keyed caching patterns.

Q3. What is AbortController and how does it simplify memory leak prevention in React effects?

AbortController produces an AbortSignal that can be passed to fetch (which cancels the request when aborted), and as { signal } in addEventListener (which removes the listener when aborted). By creating one AbortController per useEffect and calling controller.abort() in the cleanup return, you can cancel all associated operations in a single call — no separate removeEventListener calls, no manual fetch cancel tracking. This is cleaner and safer than managing multiple cleanup references. Additionally, signal.aborted lets async code check whether the effect has been torn down before updating state, preventing React's "setState on unmounted component" warning pattern.

Q4. What is the three-snapshot method for detecting memory leaks and why is forcing GC between snapshots important?

The three-snapshot method: take a baseline snapshot, perform the suspected leaking action multiple times, force GC, then take a second snapshot and compare. Without forcing GC before the second snapshot, the heap contains objects that are actually unreachable but haven't been collected yet — they appear as candidates for investigation when they're not actually leaked. Clicking "Collect garbage" in the Memory panel triggers a full GC cycle, leaving only genuinely retained objects. The Comparison view then shows the net change in object counts (+Delta) since the baseline. Objects with positive Delta after a forced GC are genuinely leaked — the GC ran and still couldn't collect them because something holds a reference.

Q5. Why is WeakRef.deref() potentially unsafe as a state store, and what is it appropriate for?

WeakRef allows the GC to collect the referenced object at any time after the WeakRef is created. deref() returns the object if it's still alive, or undefined if it's been collected. The timing of collection is entirely at the GC's discretion — there's no guarantee that an object alive at one deref() call is still alive at the next. If you use WeakRef to store authoritative state (the canonical source of truth for some value), a collection between reads makes the state silently disappear, which is a subtle and hard-to-reproduce bug. WeakRef is appropriate for caches where the absent value triggers a re-fetch (thumbnail cache, computed results), not for state whose absence would cause incorrect behaviour. Always handle the undefined case at every deref() call site.

Q6. Why should memory leak profiling always be done against a production build rather than the development server?

React StrictMode (enabled in development) intentionally mounts → unmounts → remounts every component to surface bugs in cleanup logic. This doubles effect-related allocations and creates noise in heap snapshots. Development-mode React includes extensive internal bookkeeping objects (fiber debug info, component stacks) that do not exist in production. Source maps and unminified code inflate function objects and string sizes. Next.js next dev includes hot-reloading infrastructure. All of these inflate the measured heap size and create objects that look like leaks but are development-only. A leak that appears to be 50 retained objects in development may be 10 in production — or vice versa. Run next build && next start and profile against the production server for accurate results.

On this page