Module: JavaScript
JavaScript·084·4 MIN READ

084: Memory Management and Garbage Collection

TOPICS COVERED: 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:

  1. allocate memory;
  2. use the value;
  3. eventually make it unreachable;
  4. allow the engine to reclaim it.
js
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.

js
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:

js
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

js
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:

js
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:

js
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.

js
const id = setInterval(refresh, 5000);

// later
clearInterval(id);

If a component is destroyed, clear intervals/timeouts that no longer make sense.

Growing Caches

js
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

js
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.

js
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

js
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.

js
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:

  1. open a modal;
  2. close it;
  3. repeat 20 times;
  4. force garbage collection in DevTools if the tool/environment allows;
  5. compare heap snapshots;
  6. 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:

text
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.

js
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 delete or null instantly 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.