DEV Community

Weather Clock Dash
Weather Clock Dash

Posted on

Hunting JavaScript Memory Leaks with Firefox DevTools: Heap Snapshots, Timers, and Event Listeners

Memory leaks in browser JavaScript are subtle, often hard to reproduce, and can cause tabs to gradually consume gigabytes of RAM. Firefox's DevTools have excellent memory analysis tools. This guide shows how to find and fix common memory leak patterns using Firefox's Memory and Performance panels.

What Counts as a Memory Leak in JS

A JavaScript memory leak is memory that's allocated but can't be garbage collected because references still exist, even though the code no longer needs that data. The GC can only free memory that's unreachable — leaked objects remain reachable via some reference chain.

Common patterns:

  1. Event listeners not removed when elements are destroyed
  2. Closures capturing large objects they don't need
  3. Cache objects that grow unboundedly
  4. Timers (setInterval) keeping callbacks and their closures alive
  5. Detached DOM nodes referenced by JS

Setting Up Firefox's Memory Profiler

Open DevTools → Memory tab (if it's not visible: click the + gear icon in DevTools to enable it).

The Memory panel has three views:

  • Heap Snapshot: captures all live objects at a point in time
  • Allocation Timeline: records all allocations during a period
  • Censuses: object counts by type

For leak detection, use Heap Snapshot.

Taking and Comparing Snapshots

The core leak detection workflow:

  1. Load the page and wait for it to stabilize
  2. Take snapshot 1 — this is your baseline
  3. Perform the action that may leak (navigate, open/close a modal, process data)
  4. Force garbage collection: in the console, run:
   // Firefox doesn't expose gc() directly, but you can trigger it:
   window.gc && window.gc(); // works in Firefox DevTools console
Enter fullscreen mode Exit fullscreen mode
  1. Take snapshot 2
  2. Repeat the action
  3. Take snapshot 3

What to look for: if snapshot 3 has significantly more objects than snapshot 1, you have a leak. The snapshot diff view (select snapshot 3, choose "compare with snapshot 1") shows what was added.

Analyzing Snapshot Diffs

After taking multiple snapshots, click on the latest snapshot and use the comparison dropdown. The diff shows:

  • Objects added (blue positive numbers)
  • Objects removed (red negative numbers)
  • Net change

Sort by "Count" descending. Look for:

  • Unexpected large counts of DOM nodes (especially HTMLElement variants)
  • Many copies of a specific class name (your code's objects)
  • Growing Array, Object, or closure counts

Click any object type to see individual instances and their retainer paths — the chain of references keeping them alive.

Common Leak: Event Listeners

// LEAK: listener added but never removed
class DataViewer {
  constructor(element) {
    this.element = element;
    // This closure keeps 'this' alive as long as document exists
    document.addEventListener('keydown', (e) => {
      if (e.key === 'Escape') this.close();
    });
  }

  close() {
    this.element.remove();
    // The keydown listener on document still references 'this'!
  }
}
Enter fullscreen mode Exit fullscreen mode

Fix: save the listener reference and remove it:

class DataViewer {
  constructor(element) {
    this.element = element;
    this._keyHandler = (e) => {
      if (e.key === 'Escape') this.close();
    };
    document.addEventListener('keydown', this._keyHandler);
  }

  close() {
    this.element.remove();
    document.removeEventListener('keydown', this._keyHandler);
  }
}
Enter fullscreen mode Exit fullscreen mode

In Firefox DevTools, find the leak by searching heap snapshots for your class name, then expanding the retainer path — you'll see the EventListener object in the path.

Common Leak: Detached DOM Nodes

const cache = [];

function createWidget() {
  const div = document.createElement('div');
  document.body.appendChild(div);
  cache.push(div); // keeps reference even after removal
  return div;
}

function removeWidget(div) {
  div.remove(); // removed from DOM but still in cache[]
  // div is now a "detached DOM node" — alive but not visible
}
Enter fullscreen mode Exit fullscreen mode

Fix: remove from cache when removing from DOM. Or use WeakRef/WeakMap so the reference doesn't prevent GC.

Firefox DevTools shows "Detached DOM tree" as a category in heap snapshots. If you see many of these, you have retained DOM references.

Common Leak: Timers

class PollingComponent {
  start() {
    this.intervalId = setInterval(() => {
      this.fetchData(); // closure captures 'this'
    }, 5000);
  }

  destroy() {
    // Forgot to call clearInterval — timer fires forever
    // and keeps 'this' alive via closure
  }
}
Enter fullscreen mode Exit fullscreen mode

Fix:

destroy() {
  clearInterval(this.intervalId);
}
Enter fullscreen mode Exit fullscreen mode

In heap snapshots, timer leaks appear as unexpected instances of your class surviving past their expected lifetime.

Common Leak: Closures Capturing Large Data

function processLargeData(bigArray) {
  // bigArray has 100K items
  const summary = bigArray.reduce((acc, item) => acc + item.value, 0);

  return function getSummary() {
    return summary; // Only needs summary, but closure...
    // actually this is fine — closure only captures summary
  };
}

// BAD PATTERN:
function createHandler(bigArray) {
  const summary = bigArray.reduce((acc, item) => acc + item.value, 0);

  return {
    getSummary: () => summary,
    // All these other methods also close over bigArray implicitly
    // because they're in the same scope where bigArray is defined
    getFirst: () => bigArray[0], // this actually uses bigArray
    getCount: () => bigArray.length, // keeps bigArray alive
  };
}
Enter fullscreen mode Exit fullscreen mode

Fix: extract only needed data before creating closures.

Allocation Timeline for Finding Allocation Sites

The Allocation Timeline view records where memory is being allocated:

  1. DevTools → Memory → ◉ Record (Allocation Timeline)
  2. Perform the operation
  3. Stop recording

The timeline shows allocation spikes. Click on a spike to see which call stack was allocating at that moment — this pinpoints the source code line causing excessive allocations.

Performance Panel Memory Track

DevTools → Performance → enable "Memory" checkbox before recording. The Performance panel overlays a memory usage graph on the timeline.

Look for:

  • Steady upward slope in JS heap size (classic leak symptom)
  • No sawtooth pattern (GC should periodically reclaim memory — if it never goes back down, memory isn't being freed)
  • Heap size jumps after specific events

WeakRef and FinalizationRegistry (Modern JS)

For caches, consider WeakRef so entries don't prevent GC:

const cache = new Map();

function cacheValue(key, value) {
  cache.set(key, new WeakRef(value));
}

function getCached(key) {
  const ref = cache.get(key);
  if (!ref) return undefined;

  const value = ref.deref(); // undefined if GC'd
  if (!value) {
    cache.delete(key); // clean up stale entry
    return undefined;
  }
  return value;
}
Enter fullscreen mode Exit fullscreen mode

WeakRef is supported in Firefox 79+. Note: GC timing is not deterministic, so WeakRef is for caches where stale entries are acceptable.

Quick Memory Checks

Before full profiling, check basic metrics:

// Total JS heap size
performance.memory // Only available in Chrome
// Not available in Firefox — use DevTools instead

// Check for obvious timer leaks in console
// Each active setInterval shows up in this count:
// (No direct API, but DevTools Performance shows active timers)
Enter fullscreen mode Exit fullscreen mode

In Firefox, about:memory → Measure → look at the "window-objects" section for your page's memory footprint.

Top comments (0)