Skip to content

Memory Leak Patterns

advanced19 min read

Memory Leaks in a Garbage-Collected Language

"JavaScript has garbage collection, so memory leaks can't happen." You've heard this, maybe even believed it. It's wrong. The GC collects unreachable objects. A memory leak occurs when objects remain reachable but are no longer needed. The GC can't read your mind — it only follows references.

// This is a memory leak
const cache = [];
function processRequest(data) {
  const result = heavyComputation(data);
  cache.push(result);  // Never removed — cache grows without bound
  return result;
}

The cache array is reachable from module scope. Every result added is kept alive forever. The GC sees a perfectly valid reference chain: global → module → cache → results. It has no way to know you don't need results from 10,000 requests ago.

Mental Model

Think of memory leaks as rooms in a house where you left the lights on. The house (GC) only turns off lights in rooms that are completely sealed off (unreachable). If there's a hallway connecting a room to the rest of the house, the lights stay on — even if nobody has visited that room in months. A memory leak is a forgotten hallway to a room full of furniture you'll never use again.

Pattern 1: Forgotten Event Listeners

This is the number one leak in web applications, bar none. Every addEventListener creates a reference from the DOM node to the callback. If the callback closes over application state, that state is kept alive as long as the listener exists.

// LEAK: listener is never removed
function initFeature() {
  const data = loadLargeDataset();  // 50 MB of data

  window.addEventListener('scroll', () => {
    updateVisualization(data);  // Closure holds 'data' alive
  });
}

// Feature is "destroyed" but the listener remains
// 'data' (50 MB) is still reachable through the listener's closure

The fix is always the same: store the reference and remove the listener when done:

function initFeature() {
  const data = loadLargeDataset();

  const onScroll = () => updateVisualization(data);
  window.addEventListener('scroll', onScroll);

  // Return a cleanup function
  return () => {
    window.removeEventListener('scroll', onScroll);
    // 'data' becomes unreachable once the closure is gone
  };
}

const cleanup = initFeature();
// Later, when feature is no longer needed:
cleanup();
Common Trap

Anonymous arrow functions can't be removed with removeEventListener because you don't have a reference to the same function object. This is a common source of leaked listeners:

// CAN'T remove this — the arrow function is anonymous
element.addEventListener('click', () => handleClick());

// CAN remove this — named function reference
const handler = () => handleClick();
element.addEventListener('click', handler);
element.removeEventListener('click', handler);

In React, the useEffect cleanup function handles this automatically — but only if you actually return a cleanup function.

Quiz
An event listener closes over a 100 MB dataset. The feature is 'unmounted' but the listener is not removed. What happens?

Pattern 2: Closures Over Large Scopes

This one surprises people. Closures capture their entire lexical scope — not just the variables they actually use. If a closure captures one variable from a scope that also contains large objects, those large objects are retained too.

function processData() {
  const rawData = fetchHugePayload();      // 200 MB
  const summary = computeSummary(rawData);  // 1 KB

  // This closure only uses 'summary', but captures the entire scope
  return function getSummary() {
    return summary;
  };
}

const getter = processData();
// rawData (200 MB) is still in memory — captured by the closure's scope
How V8 Actually Handles Closure Scope

V8 performs scope analysis during compilation. In optimized code, V8 can sometimes eliminate unused variables from the closure's context. However, this optimization is not guaranteed and depends on:

  1. Whether the function is optimized by TurboFan (interpreted code keeps the full scope)
  2. Whether eval() is used anywhere in the scope chain (eval prevents any elimination)
  3. Whether a debugger is attached (debuggers need full scope access)

In practice, you should assume the full scope is captured and restructure your code to avoid capturing large objects:

function processData() {
  const summary = computeSummary(fetchHugePayload());
  // rawData was never a variable — it's immediately consumed
  return function getSummary() {
    return summary;
  };
}
Quiz
A closure references variable 'x' from its parent scope. The parent scope also contains variable 'hugeArray' that the closure never uses. Is hugeArray kept alive?

Pattern 3: Detached DOM Trees

Here's one that's particularly nasty to debug. A detached DOM tree is a subtree of DOM nodes removed from the document but still referenced in JavaScript. The entire subtree stays in memory.

let cachedElement = null;

function showModal() {
  const modal = document.createElement('div');
  modal.innerHTML = `<div class="modal">
    <div class="content"><!-- large subtree --></div>
  </div>`;
  document.body.appendChild(modal);
  cachedElement = modal;  // JS reference
}

function hideModal() {
  cachedElement.remove();  // Removed from DOM...
  // ...but 'cachedElement' still holds a reference
  // The entire modal subtree is a detached DOM tree — still in memory
}

The fix: null out the reference after removal:

function hideModal() {
  cachedElement.remove();
  cachedElement = null;  // Release the reference
}

But wait, it gets worse. A single reference to any node in the tree keeps the entire tree alive:

function setupTable() {
  const table = document.createElement('table');
  // ... add 1000 rows
  document.body.appendChild(table);

  const firstCell = table.querySelector('td');
  // 'firstCell' keeps the entire table alive even after removal
  return firstCell;
}

const cell = setupTable();
document.querySelector('table').remove();
// Table is detached but cell → row → tbody → table → all 1000 rows still in memory
Quiz
A table with 1000 rows is removed from the DOM. JavaScript holds a reference to one cell. How much memory is retained?

Pattern 4: Uncleared Timers and Intervals

setInterval creates a recurring reference that persists until explicitly cleared:

function startPolling(url) {
  const data = { buffer: new ArrayBuffer(1024 * 1024) };  // 1 MB

  setInterval(() => {
    fetch(url).then(res => {
      // Process response using data.buffer
      processWithBuffer(res, data.buffer);
    });
  }, 5000);
  // Interval runs forever. Closure keeps 'data' alive forever.
}

// Even if the component unmounts, the interval continues

The fix: store the interval ID and clear it:

function startPolling(url) {
  const data = { buffer: new ArrayBuffer(1024 * 1024) };

  const intervalId = setInterval(() => {
    fetch(url).then(res => processWithBuffer(res, data.buffer));
  }, 5000);

  return () => {
    clearInterval(intervalId);
    // data.buffer becomes eligible for GC
  };
}

const stopPolling = startPolling('/api/status');
// Later:
stopPolling();

setTimeout is less dangerous (it fires once and the closure can be collected after), but recursive setTimeout has the same issue as setInterval.

Pattern 5: Growing Caches Without Eviction

This is the most subtle leak of all — because it looks like perfectly reasonable code:

const cache = new Map();

function getUser(id) {
  if (cache.has(id)) return cache.get(id);
  const user = fetchUserSync(id);
  cache.set(id, user);  // Entry added, never removed
  return user;
}
// After 100,000 unique users: 100,000 cached user objects

Caches need eviction policies:

class LRUCache {
  #map = new Map();
  #maxSize;

  constructor(maxSize) {
    this.#maxSize = maxSize;
  }

  get(key) {
    if (!this.#map.has(key)) return undefined;
    const value = this.#map.get(key);
    // Move to end (most recently used)
    this.#map.delete(key);
    this.#map.set(key, value);
    return value;
  }

  set(key, value) {
    if (this.#map.has(key)) this.#map.delete(key);
    this.#map.set(key, value);
    // Evict oldest if over capacity
    if (this.#map.size > this.#maxSize) {
      const oldestKey = this.#map.keys().next().value;
      this.#map.delete(oldestKey);
    }
  }
}

This uses the fact that JavaScript Map preserves insertion order — keys().next().value is the oldest entry.

Quiz
A Map-based cache has no eviction policy and is used in a request handler serving 10,000 unique users per hour. After 24 hours, what is the likely state?

Pattern 6: Global References and Module-Level State

// module-level.js
const subscribers = [];

export function subscribe(callback) {
  subscribers.push(callback);
}

export function unsubscribe(callback) {
  const idx = subscribers.indexOf(callback);
  if (idx !== -1) subscribers.splice(idx, 1);
}

// consumer.js — LEAK: forgets to unsubscribe
import { subscribe } from './module-level.js';

function init() {
  const heavyState = loadState();  // 50 MB
  subscribe(() => render(heavyState));
  // If this component is destroyed without calling unsubscribe,
  // the closure (and heavyState) is retained in the subscribers array
}

Debugging: Heap Snapshot Comparison

Now let's talk about actually finding these leaks. The most reliable technique is heap snapshot comparison:

  1. Take snapshot A (baseline): after the app has been running normally
  2. Perform the suspected leaking action (navigate to a page, open/close a modal, etc.)
  3. Return to baseline state (navigate back, close the modal)
  4. Force garbage collection (click the trash can icon in DevTools Memory panel)
  5. Take snapshot B (after returning to baseline)
  6. Compare B to A: any objects in B that weren't in A are potential leaks

In Chrome DevTools:

  • Open Memory tab → select Heap snapshot
  • Take first snapshot → perform action → undo action → take second snapshot
  • Select second snapshot → change view to Comparison → select first snapshot as baseline
  • Sort by Size Delta or Count Delta to find growing object types
What to Look For in Comparisons

Detached DOM trees: filter by "Detached" in the comparison view. Any detached nodes that appeared after your action and persisted after undoing it are leaks.

Event listeners: filter by "EventListener" — increasing count after action/undo cycles means listeners aren't being cleaned up.

Closures: look for "(closure)" entries with unexpectedly large retained sizes — these indicate closures capturing large scopes.

Growing arrays/maps: look for Array or Map entries with increasing element counts that shouldn't be growing.

Quiz
You take two heap snapshots: one before opening a modal, one after opening and closing it 5 times. The comparison shows 5 detached HTMLDivElement trees. What does this indicate?

Production Leak Detection

The Sawtooth Pattern

Once you know what to look for, you can spot leaks from a memory graph alone. A healthy application shows a sawtooth pattern: memory rises as objects are allocated, drops when GC collects them, rises again:

Memory
  |  /|  /|  /|  /|
  | / | / | / | / |
  |/  |/  |/  |/  |
  └──────────────── Time

A leaking application shows a rising sawtooth: each peak is higher than the last because some objects survive every GC cycle:

Memory
  |          /|  /|
  |       /|/ |/ |
  |    /|/ |  |  |
  | /|/ |  |  |  |
  |/ |  |  |  |  |
  └──────────────── Time

Automated Leak Detection

// Simple production leak detector
const SAMPLE_INTERVAL = 60_000; // 1 minute
const TREND_WINDOW = 10;        // 10 samples = 10 minutes

const memorySamples = [];

setInterval(() => {
  if (globalThis.gc) globalThis.gc(); // Only if --expose-gc
  const usage = process.memoryUsage();

  memorySamples.push(usage.heapUsed);
  if (memorySamples.length > TREND_WINDOW) memorySamples.shift();

  if (memorySamples.length === TREND_WINDOW) {
    const isRising = memorySamples.every((val, i) =>
      i === 0 || val > memorySamples[i - 1]
    );
    if (isRising) {
      console.error(`Potential memory leak: heap grew monotonically over ${TREND_WINDOW} minutes`);
    }
  }
}, SAMPLE_INTERVAL);

Key Rules

Key Rules
  1. 1Memory leaks in JS = objects reachable but no longer needed. The GC follows references, not intent.
  2. 2Event listeners: always store the handler reference and remove it during cleanup. Anonymous functions can't be removed.
  3. 3Closures capture the entire lexical scope. Restructure code so closures don't share scope with large objects.
  4. 4Detached DOM trees: one JS reference to any node in a subtree retains the entire subtree.
  5. 5Timers: setInterval runs forever unless cleared. Store the ID and clearInterval on cleanup.
  6. 6Caches: always add an eviction policy (LRU, TTL, max-size). Unbounded caches are unbounded leaks.
  7. 7Heap snapshot comparison (before/after an action) is the most reliable way to identify what's leaking.
  8. 8Rising sawtooth in memory graphs = leak. Flat sawtooth = healthy. Monitor in production.