084: Memory Management and Garbage Collection
Outcomes
By the end of this lesson, you can:
- explain JavaScript's automatic memory management;
- describe reachability as the central garbage-collection concept;
- distinguish allocation, use, and release/reclamation;
- identify common browser memory leaks;
- understand closures, event listeners, timers, caches, and DOM references as possible retention paths;
- use weak collections when object-lifetime semantics fit.
Memory Lifecycle
At a high level:
- allocate memory;
- use the value;
- eventually make it unreachable;
- allow the engine to reclaim it.
function buildOrder() {
const order = {
id: "ORD-1",
items: new Array(1000).fill("item"),
};
return order;
}
let order = buildOrder();
order = null;
Assigning null does not directly free memory. It removes one reference. The garbage collector can reclaim the object if it is no longer reachable through any live path.
Reachability
JavaScript engines use sophisticated garbage collectors, but the beginner mental model is:
reachable values stay; unreachable values can be collected.
Roots include things such as:
- active execution contexts;
- global objects;
- referenced DOM nodes;
- active callbacks/listeners;
- closures reachable from live functions.
Closures and Retention
Closures are not leaks by themselves.
function createCounter() {
let count = 0;
return () => ++count;
}
The closed-over count remains because it is intentionally reachable.
But a closure can accidentally retain a much larger object graph:
function createHandler(hugeDataset) {
return () => {
console.log(hugeDataset.length);
};
}
If the handler remains registered for the life of the app, the dataset may remain reachable too.
Event Listener Leak Pattern
function mountPanel() {
const panel = document.querySelector("#panel");
window.addEventListener("resize", () => {
console.log(panel.getBoundingClientRect());
});
}
If the panel is later removed but the window listener remains, the closure may retain the panel.
Prefer explicit lifecycle cleanup:
function mountPanel() {
const panel = document.querySelector("#panel");
function handleResize() {
console.log(panel.getBoundingClientRect());
}
window.addEventListener("resize", handleResize);
return function unmount() {
window.removeEventListener("resize", handleResize);
};
}
AbortSignal for Listener Cleanup
Modern event listeners can use a signal:
const controller = new AbortController();
window.addEventListener(
"resize",
handleResize,
{ signal: controller.signal }
);
// later
controller.abort();
This can simplify cleanup of a group of listeners.
Timers
Long-lived timers retain their callbacks.
const id = setInterval(refresh, 5000);
// later
clearInterval(id);
If a component is destroyed, clear intervals/timeouts that no longer make sense.
Growing Caches
const cache = new Map();
function remember(key, value) {
cache.set(key, value);
}
If keys grow forever, the cache grows forever.
A real cache needs a policy:
- size limit;
- expiration;
- eviction;
- weak-key semantics where appropriate.
WeakMap
const metadata = new WeakMap();
function attachMetadata(element, data) {
metadata.set(element, data);
}
Because WeakMap keys are weakly held, metadata can disappear when the key object becomes unreachable elsewhere.
WeakMap is not a universal leak fixer. It is appropriate when the metadata's lifetime should follow an object.
Detached DOM Trees
A DOM node removed from the document can still live if JavaScript references it.
let oldPanel = document.querySelector("#panel");
oldPanel.remove();
// still referenced by oldPanel
Set long-lived references to null or replace them when they are no longer needed, especially in large applications and component systems.
Memory Leaks versus High Memory Use
High memory use is not automatically a leak.
A leak usually means memory that should become reclaimable remains reachable unintentionally and continues accumulating.
Ask:
- Does memory grow repeatedly after the same action?
- Does it return toward baseline after cleanup/GC?
- Are detached elements accumulating?
- Are listeners/timers/caches growing?
Worked Example: Disposable Controller
class SearchController {
#controller = new AbortController();
#timerId = null;
constructor(input) {
this.input = input;
input.addEventListener(
"input",
this.handleInput,
{ signal: this.#controller.signal }
);
}
handleInput = () => {
clearTimeout(this.#timerId);
this.#timerId = setTimeout(() => {
console.log(this.input.value);
}, 250);
};
destroy() {
this.#controller.abort();
clearTimeout(this.#timerId);
this.#timerId = null;
}
}
The class defines a clear resource lifecycle.
Garbage Collection Is Nondeterministic
Do not write code that depends on garbage collection happening immediately.
object = null;
// You cannot assume memory is reclaimed on the next line.
The engine chooses when to collect based on its own heuristics.
Deep Debugging: Retaining Paths and Leak Reproduction
When investigating a suspected leak, create a repeatable scenario.
Example:
- open a modal;
- close it;
- repeat 20 times;
- force garbage collection in DevTools if the tool/environment allows;
- compare heap snapshots;
- inspect whether modal nodes/controllers keep accumulating.
The important question is not "is this object large?" but why is this object still reachable?
A retaining path might look conceptually like:
Window → global controllers → old ModalController → clickHandler closure → detached modal element
Fix the ownership boundary rather than manually nulling random values.
FinalizationRegistry warning
JavaScript provides WeakRef and FinalizationRegistry, but they are advanced tools with nondeterministic timing.
They should not be used to implement correctness-critical cleanup.
const registry = new FinalizationRegistry((label) => {
console.log(`${label} collected at some later time`);
});
This callback may run much later or not before process/page termination. Lifecycle cleanup should remain explicit whenever correctness depends on it.
Mistakes and Debugging
- believing
deleteornullinstantly frees memory; - registering global listeners without cleanup;
- keeping intervals alive after UI teardown;
- unbounded Maps/arrays used as caches;
- holding detached DOM nodes in global variables;
- taking one memory snapshot and calling every large object a leak.
Best Practices
- Design lifecycle cleanup for long-lived applications.
- Keep global state small.
- Bound caches.
- Remove listeners/timers when owners are destroyed.
- Prefer weak collections when lifetime truly follows object identity.
- Measure memory behavior with DevTools before optimizing.
Exercises
Core
Explain why object = null is not equivalent to "free memory now."
Practice
Refactor a component with a window event listener so it exposes destroy().
Professional Extension
Build a bounded cache that stores at most 100 entries and evicts the oldest entry when capacity is exceeded.
Recap
JavaScript manages memory automatically, but developers still control reachability. Most memory leaks are really lifetime-management mistakes.
