Memory leaks in browser tabs are real and measurable. Firefox's tools for diagnosing memory issues are more powerful than most developers know. Here's a practical guide to finding and fixing browser-side memory leaks.
What Causes Browser Memory Leaks
The JavaScript garbage collector handles memory automatically, but some patterns prevent objects from being collected:
- Event listeners not removed: Adding a listener to a DOM element that gets removed, without removing the listener.
- Closures holding references: A callback holds a reference to a large object, keeping it alive indefinitely.
-
Global variables: Accidentally assigning to
windowproperties. -
Timer callbacks:
setIntervalcallbacks that reference DOM nodes that no longer exist. - Caches without eviction: Object maps that grow indefinitely.
- Detached DOM trees: Nodes removed from the DOM but still referenced in JavaScript.
Firefox Tools for Memory Debugging
about:memory
The most powerful memory tool:
- Navigate to
about:memory - Click "Measure" to take a snapshot
- Examine the tree of memory allocations
Key sections:
-
explicit/: How much memory was explicitly allocated (malloc, heap) -
heap-unclassified: Memory not classified elsewhere -
js-main-runtime-gc-heap: JavaScript GC heap -
dom/: DOM nodes and related memory
Finding leaks: Take a snapshot before a user action, perform the action multiple times, take another snapshot, click "Minimize memory usage" (forces GC), take a third snapshot. Compare: the third should be close to the first if no leak exists.
DevTools Memory Panel
DevTools → Memory panel (sometimes called "Heap snapshot")
Heap snapshot:
- Click "Take heap snapshot"
- Perform the action you suspect leaks
- Take another snapshot
- In the dropdown: "Comparison" mode
- Shows objects created between snapshots
What to look for:
- Large counts of objects that should have been cleaned up
- "Detached" DOM nodes (nodes removed from the DOM but still referenced)
- EventListener counts increasing
Shallow vs Retained size:
- Shallow size: Memory used by the object itself
- Retained size: Memory that would be freed if the object was garbage collected (includes objects only reachable through this one)
Sort by "Retained size" descending to find the objects that hold the most memory.
about:performance
Quick memory overview:
- Shows memory usage per tab
- Shows memory used by extensions
- "Close" links let you close heavy tabs immediately
Use for: Finding which tabs are using the most memory without detailed analysis.
Finding Detached DOM Nodes
Detached DOM nodes are removed from the document but still referenced in JavaScript. They don't render but consume memory.
DevTools Memory → New heap snapshot → filter by "Detached":
Heap snapshot
→ Filter: "Detached"
→ Shows all DOM nodes no longer in the document
Example leak:
// BUG: stores reference to removed element
const cache = {};
function removeElement(id) {
const el = document.getElementById(id);
cache[id] = el; // Still referenced!
el.parentNode.removeChild(el); // Removed from DOM, but in cache
}
// FIX: clear the cache when element is removed
delete cache[id];
Finding Listener Leaks
Event listeners that aren't removed are a common leak source.
DevTools Inspector → select an element → Event Listeners panel (right panel):
Shows all event listeners on the selected element, including listeners added by JavaScript.
The leak pattern:
class Component {
constructor(element) {
this.element = element;
this.handler = this.handleClick.bind(this);
this.element.addEventListener('click', this.handler);
}
destroy() {
// BUG: handler not removed!
this.element = null; // Doesn't remove the listener
}
// FIX:
destroy() {
this.element.removeEventListener('click', this.handler);
this.element = null;
}
handleClick() { /* ... */ }
}
Timer Leaks
// BUG: interval keeps running after component is destroyed
class DataPoller {
start() {
this.interval = setInterval(() => {
this.update(); // 'this' is captured in closure
}, 1000);
}
destroy() {
// Missing: clearInterval(this.interval)
}
}
// FIX:
destroy() {
clearInterval(this.interval);
this.interval = null;
}
In WeakRef pattern:
// Use WeakRef so the object can be GC'd even if the timer is running
const ref = new WeakRef(component);
const interval = setInterval(() => {
const comp = ref.deref();
if (!comp) {
clearInterval(interval); // Component gone, stop timer
return;
}
comp.update();
}, 1000);
Cache Leaks
// BUG: cache grows without bound
const imageCache = {};
function loadImage(url) {
if (imageCache[url]) return imageCache[url];
const img = new Image();
img.src = url;
imageCache[url] = img; // Cache grows forever
return img;
}
// FIX 1: Use WeakMap (allows GC when keys are collected)
const weakCache = new WeakMap();
// FIX 2: LRU cache with size limit
class LRUCache {
constructor(maxSize) {
this.maxSize = maxSize;
this.map = new Map();
}
get(key) {
if (!this.map.has(key)) return undefined;
const value = this.map.get(key);
this.map.delete(key); // Move to end
this.map.set(key, value);
return value;
}
set(key, value) {
if (this.map.has(key)) this.map.delete(key);
if (this.map.size >= this.maxSize) {
// Delete oldest entry (first in Map iteration)
this.map.delete(this.map.keys().next().value);
}
this.map.set(key, value);
}
}
WeakRef and FinalizationRegistry
Modern JavaScript provides tools for memory-aware programming:
// WeakRef: reference that doesn't prevent GC
const weakRef = new WeakRef(someObject);
// Later:
const obj = weakRef.deref();
if (obj) {
// Object still alive
} else {
// Object was garbage collected
}
// FinalizationRegistry: callback when object is GC'd
const registry = new FinalizationRegistry((heldValue) => {
console.log('Object GC\'d, cleanup for:', heldValue);
// Clean up associated resources
});
const myObject = { data: 'important' };
registry.register(myObject, 'myObject-key');
Monitoring Heap Size Over Time
Use the Memory panel's heap allocation tracker:
- DevTools → Memory → "Allocation sampling" or "Allocation tracker"
- Start recording
- Perform operations that might leak
- Stop recording
- The allocation timeline shows where allocations happened
What to look for: A growing heap that never drops back after GC — that's leaked memory.
Forcing Garbage Collection
Firefox DevTools Console: Click the garbage truck icon (GC button) to force a collection.
Or: about:memory → "Minimize memory usage" → forces GC and empties caches.
In code (for testing):
// Only works in Firefox DevTools (not in normal code)
// Using the Internal API (advanced):
Cu.forceGC(); // For extension code
Practical Leak Detection Workflow
- Load page fresh, note memory in about:memory
- Perform action that might leak (e.g., open/close a modal 10 times)
- Force GC (about:memory → Minimize memory usage)
- Measure memory again: if significantly higher than step 1, likely leak
- Take heap snapshots before and after, compare in Comparison view
- Find growing object types in the comparison
Rule of thumb: If memory after N repetitions of an action is proportional to N, you have a linear leak. If constant, no leak.
Top comments (0)