Skip to content

Memory Leak Detection Patterns

advanced18 min read

The Leaks Nobody Notices Until Production Burns

Here's the uncomfortable truth: most frontend apps are leaking memory right now. Not dramatically — not the kind that crashes the tab in 30 seconds. The insidious kind. The kind where your SPA runs fine for 10 minutes but gets sluggish after an hour. The kind that only shows up in production when real users navigate back and forth between views 50 times.

Memory leaks in frontend apps are fundamentally different from backend leaks. In a server, a leaked object might get cleaned up when the request ends. In a browser tab, a leaked object lives forever — or at least until the user refreshes. And modern SPAs can stay open for hours.

Mental Model

Think of your app's memory like a hotel. Objects check in (allocation) and check out (garbage collection). A memory leak is a guest who checked out at the front desk but left their luggage in the room — the room can never be cleaned and rented to someone else. The GC wants to collect the object, but something is still holding a reference to it, so it stays checked in forever.

Pattern 1: Detached DOM Trees

This is the most common leak in SPAs and the hardest to spot. A detached DOM tree is a subtree of DOM nodes that has been removed from the document but is still referenced by JavaScript.

let cachedElement = null;

function showNotification(message) {
  const el = document.createElement('div');
  el.className = 'notification';
  el.textContent = message;
  document.body.appendChild(el);

  cachedElement = el;

  setTimeout(() => {
    el.remove();
  }, 3000);
}

After el.remove(), the DOM node is detached from the document tree. But cachedElement still holds a reference. The browser cannot garbage-collect that node — or any node it references (children, attributes, associated event handlers).

The scary part? A single leaked reference to one node in a tree keeps the entire tree alive. Reference a single <tr> in a 10,000-row table, and the whole table stays in memory.

function showNotification(message) {
  const el = document.createElement('div');
  el.className = 'notification';
  el.textContent = message;
  document.body.appendChild(el);

  setTimeout(() => {
    el.remove();
  }, 3000);
}

The fix is simple: don't store references to DOM nodes in long-lived variables. If you must cache, use WeakRef:

let cachedRef = null;

function cacheElement(el) {
  cachedRef = new WeakRef(el);
}

function getCached() {
  return cachedRef?.deref();
}
Quiz
A detached DOM tree contains 500 nodes. You hold a JavaScript reference to one leaf node. How many nodes are kept in memory?

How to Detect in DevTools

  1. Open Memory panel, take a heap snapshot
  2. In the filter, type Detached
  3. You will see Detached HTMLDivElement, Detached HTMLTableElement, etc.
  4. Click any one to see its retaining path — this shows you exactly which JavaScript reference is keeping it alive
Common Trap

React and other frameworks can hide detached DOM leaks. If you store a ref to a DOM node via useRef and the component unmounts but you keep the ref in a parent component's state or a module-level variable, the entire subtree stays in memory. Always clean up refs in useEffect cleanup functions.

Pattern 2: Forgotten Event Listeners

Every addEventListener creates a reference from the target to the handler. If the target is window, document, or any long-lived DOM node, that reference persists until you explicitly removeEventListener.

function ChatRoom({ roomId }) {
  useEffect(() => {
    function onMessage(e) {
      console.log('Message:', e.data);
    }

    window.addEventListener('message', onMessage);

    // BUG: no cleanup — listener persists after unmount
  }, [roomId]);
}

Every time this component mounts with a new roomId, a new listener is added to window. Old listeners are never removed. After navigating between 100 rooms, you have 100 active listeners — each one closing over its own scope, keeping the component's variables alive.

function ChatRoom({ roomId }) {
  useEffect(() => {
    function onMessage(e) {
      console.log('Message:', e.data);
    }

    window.addEventListener('message', onMessage);

    return () => {
      window.removeEventListener('message', onMessage);
    };
  }, [roomId]);
}
Info

Anonymous arrow functions make this bug impossible to fix. window.removeEventListener('message', (e) => ...) does nothing because it is a different function reference. Always use named function references or store the handler in a variable.

The AbortController Pattern

Modern code can use AbortController for cleaner listener cleanup:

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

  window.addEventListener('resize', handleResize, {
    signal: controller.signal,
  });
  window.addEventListener('scroll', handleScroll, {
    signal: controller.signal,
  });
  document.addEventListener('keydown', handleKey, {
    signal: controller.signal,
  });

  return () => controller.abort();
}, []);

One abort() call removes all three listeners. No mismatched references, no forgotten cleanup.

Quiz
You add an event listener to window using an anonymous arrow function. Later you call removeEventListener with an identical arrow function. What happens?

Pattern 3: Closures Over Large Scopes

We covered this in depth in the closures topic, but it is worth revisiting in the context of leak detection. Closures capture references to variables in their outer scope. If a closure lives longer than you expect, those variables stay alive too.

function processData() {
  const rawData = fetch('/api/huge-dataset').then(r => r.json());
  const hugeResult = transformData(rawData);

  return function getMetadata() {
    return { processedAt: Date.now(), count: hugeResult.length };
  };
}

getMetadata only needs hugeResult.length, but it closes over the entire hugeResult array. If hugeResult is 50MB, that 50MB stays alive as long as getMetadata exists.

The fix:

function processData() {
  const rawData = fetch('/api/huge-dataset').then(r => r.json());
  const hugeResult = transformData(rawData);
  const count = hugeResult.length;

  return function getMetadata() {
    return { processedAt: Date.now(), count };
  };
}

Extract the primitive values you need before creating the closure. Now hugeResult can be garbage collected after processData returns.

V8 shared context and sibling closures

V8 creates one Context object per scope level, shared by all closures defined in that scope. If you have two functions in the same scope — one that references a tiny string and one that references a huge array — both closures share the same Context. If either closure is alive, both variables stay in memory. The fix is the same: extract only primitives before creating closures, or restructure so closures are in separate scopes.

Pattern 4: Timer References

setInterval is the classic fire-and-forget leak. Unlike setTimeout (which fires once), setInterval keeps firing forever — and holds a reference to its callback and everything the callback closes over.

function startPolling(url) {
  const results = [];

  setInterval(async () => {
    const data = await fetch(url).then(r => r.json());
    results.push(data);
    updateUI(results);
  }, 5000);
}

results grows forever. The interval never stops. If startPolling is called when a component mounts but the interval is never cleared on unmount, you have a leak that grows every 5 seconds.

function startPolling(url) {
  const results = [];

  const intervalId = setInterval(async () => {
    const data = await fetch(url).then(r => r.json());
    results.push(data);
    updateUI(results);
  }, 5000);

  return () => clearInterval(intervalId);
}

// In React:
useEffect(() => {
  const stop = startPolling('/api/data');
  return stop;
}, []);
Quiz
A setInterval callback captures a 10MB array in its closure. The component that started the interval unmounts, but clearInterval is never called. What happens?

The setTimeout Loop Pattern

A safer alternative to setInterval:

function startPolling(url, signal) {
  async function poll() {
    if (signal.aborted) return;

    const data = await fetch(url, { signal }).then(r => r.json());
    updateUI(data);

    setTimeout(poll, 5000);
  }

  poll();
}

// Usage with AbortController
const controller = new AbortController();
startPolling('/api/data', controller.signal);

// To stop:
controller.abort();

The setTimeout loop is self-healing — if an error occurs or the signal is aborted, the chain breaks naturally. No orphaned intervals.

Pattern 5: Growing Arrays, Maps, and Sets

This is the sneakiest pattern because it is not a "leak" in the traditional sense — every reference is intentional. But unbounded growth has the same effect.

const eventLog = [];

function trackEvent(event) {
  eventLog.push({
    type: event.type,
    timestamp: Date.now(),
    data: structuredClone(event.data),
  });
}

If trackEvent runs on every user interaction and eventLog is never trimmed, you have linear memory growth. After a few hours, this array could hold hundreds of thousands of entries.

Prevention Strategies

const MAX_EVENTS = 1000;
const eventLog = [];

function trackEvent(event) {
  eventLog.push({
    type: event.type,
    timestamp: Date.now(),
    data: structuredClone(event.data),
  });

  if (eventLog.length > MAX_EVENTS) {
    eventLog.splice(0, eventLog.length - MAX_EVENTS);
  }
}

For caches, use a Map with LRU eviction or a WeakMap where appropriate:

const cache = new Map();
const MAX_CACHE = 100;

function cacheResult(key, value) {
  if (cache.size >= MAX_CACHE) {
    const firstKey = cache.keys().next().value;
    cache.delete(firstKey);
  }
  cache.set(key, value);
}
What developers doWhat they should do
Storing DOM references in module-level variables or global state
Module-level variables persist for the entire page lifecycle, keeping detached DOM trees alive
Use WeakRef for DOM caches, or clean up references when nodes are removed
Adding event listeners to window/document without cleanup
Global event targets are never garbage collected, so their listeners persist forever
Always return cleanup functions from useEffect; use AbortController for bulk cleanup
Using setInterval without storing and clearing the interval ID
The browser timer system holds a strong reference to the callback indefinitely
Store the ID, clear on unmount, or use the setTimeout loop pattern with AbortController
Appending to arrays or Maps without bounds
Unbounded collections grow linearly and eventually consume all available memory
Set maximum size limits, implement LRU eviction, or use WeakMap for object keys

The Detection Workflow

Here is the systematic approach to finding memory leaks in production apps:

Quiz
You suspect a memory leak when navigating between two pages. You take a heap snapshot, navigate back and forth 10 times, then take another snapshot. Before the second snapshot, what critical step should you perform?

Real-World Leak: The React SPA Navigation Pattern

The most common real-world leak combines multiple patterns. Consider a dashboard SPA where users navigate between views:

let chartInstance = null;

function DashboardChart({ data }) {
  const canvasRef = useRef(null);

  useEffect(() => {
    chartInstance = new HeavyChartLibrary(canvasRef.current, {
      data,
      onHover: (point) => {
        showTooltip(point, data);
      },
    });

    return () => {
      // BUG: chartInstance.destroy() is called, but the
      // module-level variable still references the old instance
      chartInstance.destroy();
    };
  }, [data]);

  return <canvas ref={canvasRef} />;
}

This has three leak sources:

  1. chartInstance (module-level) keeps the old chart alive after destroy
  2. onHover closes over data, so the chart's internal listener keeps data alive
  3. The chart library likely attaches listeners to the canvas, which is now detached

The fix:

function DashboardChart({ data }) {
  const canvasRef = useRef(null);

  useEffect(() => {
    const chart = new HeavyChartLibrary(canvasRef.current, {
      data,
      onHover: (point) => {
        showTooltip(point, data);
      },
    });

    return () => {
      chart.destroy();
    };
  }, [data]);

  return <canvas ref={canvasRef} />;
}

No module-level reference. The chart instance is local to the effect. When the cleanup runs, chart.destroy() tears down internal listeners, and the local variable goes out of scope.

Key Rules
  1. 1Every addEventListener needs a corresponding removeEventListener — use AbortController for bulk cleanup
  2. 2Every setInterval needs a clearInterval on cleanup — or use the setTimeout loop pattern
  3. 3Never store DOM node references in module-level or global variables — use WeakRef if caching is needed
  4. 4Extract primitive values from large objects before creating closures over them
  5. 5Bound every growing collection — arrays, Maps, Sets all need maximum size limits in long-lived apps
1/10