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:
- Event listeners not removed when elements are destroyed
- Closures capturing large objects they don't need
- Cache objects that grow unboundedly
- Timers (
setInterval) keeping callbacks and their closures alive - 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:
- Load the page and wait for it to stabilize
- Take snapshot 1 — this is your baseline
- Perform the action that may leak (navigate, open/close a modal, process data)
- 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
- Take snapshot 2
- Repeat the action
- 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
HTMLElementvariants) - Many copies of a specific class name (your code's objects)
- Growing
Array,Object, orclosurecounts
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'!
}
}
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);
}
}
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
}
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
}
}
Fix:
destroy() {
clearInterval(this.intervalId);
}
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
};
}
Fix: extract only needed data before creating closures.
Allocation Timeline for Finding Allocation Sites
The Allocation Timeline view records where memory is being allocated:
- DevTools → Memory → ◉ Record (Allocation Timeline)
- Perform the operation
- 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;
}
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)
In Firefox, about:memory → Measure → look at the "window-objects" section for your page's memory footprint.
Top comments (0)