Where JavaScript Memory Leaks Actually Hide (And How to Find Them)
3 min read
The bug report never says "memory leak." It says: "the app gets slow if you leave it open all day." Security analysts kept our dashboards open for entire shifts, which made leak-hunting a recurring part of my job — and taught me that JavaScript leaks aren't exotic. They hide in the same four places, in every codebase, wearing the same disguises. Here's each one, its fix, and how to actually catch them in DevTools.
First, the mental model: JavaScript frees memory that's unreachable. Leaks are never "the GC failed" — they're always "something is still holding a reference you forgot about." Leak-hunting is reference-hunting.
Hiding spot 1: Forgotten event listeners
The most common leak in SPAs. A component adds a listener to something that outlives it
— window, document, a shared element — and never removes it:
// ❌ Every mount adds a listener; no unmount removes it
function mountChart(container, data) {
window.addEventListener('resize', () => redraw(container, data));
}Navigate to this view fifty times and fifty listeners are alive, each one's closure
pinning its container and data in memory. The fix is symmetry — every add has a
matching remove:
function mountChart(container, data) {
const onResize = () => redraw(container, data);
window.addEventListener('resize', onResize);
return () => window.removeEventListener('resize', onResize); // cleanup
}Framework versions of the same rule: return the cleanup from React's useEffect;
use Angular's takeUntilDestroyed; disconnect your observers. Modern bonus:
AbortController can remove many listeners at once via signal.
Hiding spot 2: Uncleared intervals and timeouts
setInterval runs forever unless told otherwise — including after the UI that needed it
is gone:
// ❌ Polls forever, even after the widget is removed
setInterval(fetchLatestAlerts, 5000);
// ✅ Keep the handle; clear it on teardown
const timer = setInterval(fetchLatestAlerts, 5000);
// later, in cleanup:
clearInterval(timer);Intervals leak twice: the callback's closure keeps its captured objects alive, and the work itself keeps running — our "slow after lunch" dashboard turned out to be polling for six widgets the user had closed hours earlier. Any recurring timer without a stored handle and a cleanup path is a leak already in progress.
Hiding spot 3: Closures holding large objects
Closures keep their backpack alive — and the backpack holds whatever was in scope, not just what the function uses now:
// ❌ The tiny handler keeps the entire 50MB dataset reachable
function processReport(rawData /* 50MB */) {
const summary = summarize(rawData); // small
onExportClick(() => {
download(summary); // only needs summary — but rawData is in scope
});
}Whether rawData gets retained depends on engine optimizations you shouldn't gamble on.
The fix is to narrow what long-lived callbacks close over: compute the small thing,
let the big thing go out of scope, and make sure only the small thing is captured. In
practice: beware long-lived subscriptions created inside functions that handled large
payloads.
Hiding spot 4: Detached DOM nodes
Removing an element from the page doesn't free it if JavaScript still references it:
// ❌ Removed from the DOM, retained by the cache
const cache = {};
cache.resultsPanel = document.querySelector('#results');
// ...later, a re-render replaces #results
cache.resultsPanel.remove(); // gone from the page — alive in memoryA detached node retains its entire subtree — one cached panel can pin thousands of elements. Fixes: null out stored references on teardown, don't build element caches without lifecycle management, and prefer re-querying over long-lived element storage. DevTools calls these out by name, which brings us to the hunt.
Actually finding leaks with DevTools
The workflow that finds all four types (Chrome → Memory panel):
- Confirm it's real: Performance panel → record while using the suspect flow → a sawtooth heap that ratchets upward after each GC is your confirmation.
- Three-snapshot compare: take a heap snapshot; perform the suspected leak cycle (open view → close view) several times; take another. In the second snapshot, use the Comparison view and sort by size delta — what grew that shouldn't have?
- Read the retainers. Click a leaked object and DevTools shows its retainer chain — the exact path of references keeping it alive. That path names your culprit: a listener, a timer callback's context, a cache. Search "Detached" in the snapshot to find orphaned DOM trees directly.
Do the cycle test — open, close, repeat, and memory returns to baseline — as part of finishing any long-lived view. Leaks are far cheaper to catch the week they're written than the month someone's shift-long session starts crawling. Your users won't file "memory leak" tickets either; they'll just quietly stop leaving your app open.