Course: JavaScript Advanced
Lesson 13: Memory Basics
Level
Advanced
Estimated Reading Time
10–15 minutes
Goal
By the end of this lesson, the learner will be able to:
- Identify common sources of memory leaks in JavaScript applications
- Use
WeakMapandWeakSetto associate data with objects without preventing garbage collection - Clean up event listeners and timers correctly to prevent leaks in long-lived applications
Frontend Development Context
Memory leaks in frontend applications don't always crash the browser — often they cause gradual slowdowns that users notice after minutes or hours of use. Single-page applications (SPAs) are especially vulnerable because they never fully reload the page; JavaScript accumulates references across route changes, component mounts and unmounts, and event subscriptions. Understanding how JavaScript's garbage collector works and what prevents objects from being collected is essential for building applications that stay fast over time.
At the advanced level, memory management means knowing how closures can accidentally keep large objects alive, how event listeners on long-lived DOM nodes hold component closures in memory, and why WeakMap and WeakSet exist as GC-friendly alternatives to plain Map and Set for associating metadata with objects.
Explanation
JavaScript uses automatic garbage collection with a mark-and-sweep algorithm. The garbage collector (GC) periodically finds objects that are no longer reachable from any root (global variables, the call stack, active closures) and reclaims their memory. An object is kept alive as long as any reachable reference to it exists. A memory leak occurs when objects that should no longer be needed are kept alive by references that are never cleaned up.
Event listener leaks are one of the most common causes of memory leaks in SPAs. When a component adds an event listener to a DOM element or the global window/document and then the component's DOM is removed or replaced, the listener still holds a reference to the component's closure. The component cannot be garbage collected as long as that listener exists. The fix is to always call removeEventListener when the component is destroyed.
Timer leaks follow the same pattern. A setInterval callback holds a closure, and as long as the interval is running, that closure (and everything it references) is kept alive. If the component that set up the interval is removed from the page without clearing the interval, the timer and its references persist indefinitely.
Closures over large objects can cause unexpected retention. If a closure captures a reference to a large dataset — an array of thousands of items, a large canvas buffer — that entire dataset stays in memory as long as the closure exists. This happens subtly when event handlers or setTimeout callbacks reference outer-scope variables that hold large values.
WeakMap and WeakSet hold weak references to their keys (for WeakMap) or values (for WeakSet). A weak reference does not count for the purposes of garbage collection — if the only reference to an object is through a WeakMap key, the GC can still collect that object. The WeakMap entry is then automatically removed. This makes WeakMap ideal for storing metadata associated with DOM elements or objects where you don't want to control the object's lifetime.
Code Example
This example demonstrates event listener cleanup, a timer leak and its fix, and using WeakMap to store per-element metadata without preventing GC.
// --- Event listener cleanup ---
class Tooltip {
constructor(trigger, content) {
this.trigger = trigger;
this.content = content;
this.handleMouseEnter = () => this.show(); // arrow function stored for removal
this.handleMouseLeave = () => this.hide();
this.trigger.addEventListener("mouseenter", this.handleMouseEnter);
this.trigger.addEventListener("mouseleave", this.handleMouseLeave);
}
show() { console.log("Showing:", this.content); }
hide() { console.log("Hiding:", this.content); }
destroy() {
// Must pass the exact same function reference to removeEventListener
this.trigger.removeEventListener("mouseenter", this.handleMouseEnter);
this.trigger.removeEventListener("mouseleave", this.handleMouseLeave);
console.log("Tooltip destroyed and listeners removed");
}
}
// --- Timer leak and fix ---
class LiveClock {
constructor(element) {
this.element = element;
this.intervalId = null;
}
start() {
this.intervalId = setInterval(() => {
this.element.textContent = new Date().toLocaleTimeString();
}, 1000);
}
stop() {
if (this.intervalId !== null) {
clearInterval(this.intervalId); // must clear to release the closure
this.intervalId = null;
console.log("Clock stopped, interval cleared");
}
}
}
// --- WeakMap for per-element metadata ---
const elementMeta = new WeakMap();
function trackElement(element, data) {
elementMeta.set(element, data); // element is a weak key
}
function getElementMeta(element) {
return elementMeta.get(element);
}
// Simulate element lifecycle
let btn = document.createElement("button");
trackElement(btn, { clickCount: 0, createdAt: Date.now() });
console.log(getElementMeta(btn)); // { clickCount: 0, createdAt: ... }
// When btn is removed from the DOM and all strong references are gone,
// the WeakMap entry is eligible for garbage collection automatically.
// With a plain Map, you'd have to manually delete the entry.
btn = null; // strong reference gone — GC can collect the element and its WeakMap entry
// WeakSet: track which elements have been initialized
const initialized = new WeakSet();
function initializeWidget(element) {
if (initialized.has(element)) {
console.log("Already initialized");
return;
}
initialized.add(element);
console.log("Widget initialized");
// when element is GC'd, its WeakSet entry is automatically removed
}
Code Explanation
Tooltip stores event handler functions as instance properties (this.handleMouseEnter). This is critical — removeEventListener requires the exact same function reference that was used in addEventListener. If you pass an anonymous function to addEventListener, you can never remove it because you can't reference it again. By storing the handler, destroy() can cleanly remove both listeners, freeing the closure and allowing the Tooltip instance to be garbage collected.
LiveClock demonstrates timer management. The setInterval callback closes over this.element — as long as the interval runs, element (and the entire LiveClock instance via this) is kept alive. Calling stop() clears the interval and stores null, breaking the closure's hold on these references. Without stop(), the clock and its element would stay in memory forever even after being removed from the page.
The WeakMap example shows elementMeta storing per-element data. With a regular Map, setting btn = null would not allow GC to collect the button — the Map still holds a strong key reference. With WeakMap, the key is held weakly, so when all other references to btn are gone (after btn = null), the button and its metadata entry become eligible for GC automatically. No manual cleanup is needed.
WeakSet provides the same GC-friendly tracking for membership — useful for tracking which DOM elements have been initialized, which events have been handled, or which objects have been processed, without preventing those objects from being collected.
Pattern Highlights
✅ Positive Pattern: Always store event listener function references as instance or closure variables so they can be passed to
removeEventListener— anonymous inline functions cannot be removed and create permanent leaks.
⚠️ Neutral Note:
WeakMapandWeakSetare not iterable — you cannot loop over their entries or get their size. They are designed purely for weak association, not for storing collections you need to enumerate.
❌ Negative Pattern: Do not add event listeners to
window,document, or long-lived global DOM elements from within component logic without storing a cleanup reference — these listeners will outlive the component and hold its entire closure in memory.
Common Mistakes
Mistake 1: Adding a listener with an anonymous function
An anonymous inline function passed to addEventListener can never be removed because there is no reference to it.
element.addEventListener("click", function () {
doSomething(); // this listener can never be removed
});
element.removeEventListener("click", function () {
doSomething(); // different function object — removeEventListener does nothing
});
Store the function reference: const handler = () => doSomething(); element.addEventListener("click", handler); and then element.removeEventListener("click", handler) when cleanup is needed.
Mistake 2: Using Map to store element data (preventing GC)
A regular Map holds a strong reference to its keys, preventing GC even when the key object is no longer used anywhere else.
const cache = new Map();
function processElement(el) {
cache.set(el, { processed: true });
}
// Even after el is removed from the DOM and all other references are gone,
// the Map holds a strong reference — el cannot be garbage collected
Use WeakMap when the key is a DOM element or object whose lifetime you don't control, so the cache entry is automatically freed when the element is collected.
Mistake 3: Not clearing intervals when a component is removed
Setting up an interval without a corresponding cleanup creates a leak that gets worse over time.
function startPolling(userId) {
setInterval(async () => {
const data = await fetchUserData(userId);
updateUI(data); // updateUI may reference a DOM element that no longer exists
}, 5000);
// interval never cleared — runs forever even after component is gone
}
Always pair setInterval with clearInterval. In React effects, return a cleanup function; in class components, clear in componentWillUnmount; in vanilla JS, call clearInterval when the component or feature is destroyed.
Quick Recap
- Memory leaks in JavaScript occur when objects are kept reachable through references that are never cleaned up — common sources are event listeners,
setIntervalcallbacks, and closures capturing large objects. - Event listeners must be removed with
removeEventListenerusing the exact same function reference; store handler functions as variables or instance properties, never as anonymous inline functions. WeakMapholds weak references to its keys — when all strong references to a key object are gone, the GC can collect it and theWeakMapentry is automatically removed; use it for per-element or per-object metadata.- Always clear
setIntervalandsetTimeouttimers when the feature using them is destroyed; an uncleaned interval holds its callback closure alive indefinitely.