FullStack Course LogoFullStack Course
Module: JavaScript
JavaScript·084·9 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

When JavaScript creates a value, the runtime needs memory for that value and for the data it contains. A useful high-level model is:

  1. allocate memory;
  2. use the value;
  3. eventually make it unreachable;
  4. allow the engine to reclaim it.

The last step is automatic, but it is still affected by the references your code keeps. Consider this example:

js
function buildOrder() {
  const order = {
    id: "ORD-1",
    items: new Array(1000).fill("item"),
  };

  return order;
}

let order = buildOrder();

order = null;

buildOrder allocates an object and an array, and the returned object is assigned to order. Assigning null does not directly free either value. It removes the reference held by that variable. If no other live path can reach the object, the garbage collector may reclaim it later. If another object, closure, listener, or global variable still refers to it, it remains in memory.

That distinction is the foundation for debugging memory problems: your code controls references and lifetimes; the engine decides when reclaimable memory is actually collected.

Reachability

Memory leaks are often described as "objects that were not deleted," but that is not the most useful JavaScript model. Engines use sophisticated garbage collectors, while the beginner-friendly model is simpler:

reachable values stay; unreachable values can be collected.

The collector starts from a set of roots and follows references outward. Roots include things such as:

  • active execution contexts;
  • global objects;
  • referenced DOM nodes;
  • active callbacks/listeners;
  • closures reachable from live functions.

An object does not need to be globally visible to remain alive. A local variable captured by a callback can be enough, as long as that callback itself is reachable. Conversely, setting one variable to null is not sufficient if another path still leads to the same object.

Closures and Retention

Closures are not leaks by themselves. A closure keeps the variables it needs from its surrounding scope so that the returned function can continue to use them:

js
function createCounter() {
  let count = 0;

  return () => ++count;
}

The closed-over count remains because the returned function intentionally needs it. This is normal closure behavior, not a leak.

The risk appears when a long-lived closure captures more than the operation really needs. For example:

js
function createHandler(hugeDataset) {
  return () => {
    console.log(hugeDataset.length);
  };
}

As long as the handler remains registered for the life of the application, the hugeDataset object may remain reachable as well. The callback only reads length, but its closure can retain the entire object graph reachable through hugeDataset. When investigating a leak, inspect which long-lived callback owns the closure and what that closure has captured.

Event Listener Leak Pattern

Global targets such as window and document commonly outlive the UI component that registered a listener. That mismatch creates a retention path:

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 listener's closure may retain panel. Repeating the mount operation can then leave an accumulating set of listeners and detached panels. The issue is not that event listeners are inherently bad; it is that their owner and teardown boundary are not explicit.

Prefer registering a named handler and returning cleanup as part of the component's lifecycle:

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);
  };
}

The same function object is passed to addEventListener and removeEventListener, so the listener can actually be removed. Calling the returned unmount function when the panel is torn down makes the ownership relationship clear.

AbortSignal for Listener Cleanup

Modern event listeners can use an AbortSignal. This is useful when one lifecycle operation owns several listeners and they should all be removed together:

js
const controller = new AbortController();

window.addEventListener(
  "resize",
  handleResize,
  { signal: controller.signal }
);

// later
controller.abort();

Calling abort() signals the listener to be removed. Keep the controller with the component or operation that owns the listener; otherwise the cleanup mechanism can become difficult to reach and use. This pattern can simplify cleanup of a group of listeners, but it does not remove the need to define when the owner is destroyed.

Timers

Timers are another source of long-lived callbacks. An interval retains its callback until it is cleared:

js
const id = setInterval(refresh, 5000);

// later
clearInterval(id);

If refresh closes over component state, DOM nodes, or other objects, the timer can keep those values reachable. When a component is destroyed, clear intervals and timeouts that no longer make sense. A one-shot timeout should also be cancelled when its eventual callback would target an owner that has already gone away.

Growing Caches

Caching is a performance technique, but a cache is still a data structure with a lifetime and a capacity policy. This cache has neither a size limit nor an eviction rule:

js
const cache = new Map();

function remember(key, value) {
  cache.set(key, value);
}

If keys continue to grow forever, the cache grows forever. Values can remain reachable even when the rest of the application no longer needs them. That is an intentional retention policy, but it becomes a leak-like problem when the policy was never chosen explicitly.

A real cache needs a policy appropriate to its use case:

  • size limit;
  • expiration;
  • eviction;
  • weak-key semantics where appropriate.

For example, an application might keep only the most recent entries, discard entries after a period of inactivity, or use a cache library that combines a maximum size with least-recently-used eviction. Weak keys are a different lifetime choice, not a replacement for every cache policy.

WeakMap

WeakMap is useful when metadata should follow the lifetime of an object rather than keep that object alive:

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. This is a good fit for per-element metadata that should not outlive the element itself.

There are important trade-offs: weak collections are not iterable, and you cannot use them to count or enumerate their keys. WeakMap is not a universal leak fixer. Use it when the metadata's lifetime should follow an object and when those collection limitations fit the design.

Detached DOM Trees

A DOM node removed from the document can still live if JavaScript retains a reference to it. "Detached" describes its relationship to the document tree, not necessarily its eligibility for garbage collection:

js
let oldPanel = document.querySelector("#panel");

oldPanel.remove();

// still referenced by oldPanel

The node is no longer attached to the document, but oldPanel is still a live reference. Set long-lived references to null or replace them when they are no longer needed, especially in large applications and component systems. Also inspect callbacks, collections, and controller objects that might retain the node indirectly.

Memory Leaks versus High Memory Use

High memory use is not automatically a leak. A page may legitimately need a large working set while processing an image, loading a dataset, or rendering a complex view.

A leak usually means memory that should become reclaimable remains reachable unintentionally and continues accumulating. The practical question is whether memory returns toward a stable level after the work is complete and cleanup has run.

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?

One snapshot showing a large object is not enough to establish a leak. Compare repeated runs of the same operation, allow normal cleanup and collection opportunities, and inspect retaining paths.

Worked Example: Disposable Controller

The following controller makes its cleanup boundary explicit. It owns both an input listener and a pending timer, so destroy() releases both resources:

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. The signal removes the input listener, while clearTimeout prevents a pending callback from running after destruction. Setting #timerId to null also records that no timer is currently owned. In a larger controller, destroy() is the natural place to release every listener, timer, subscription, or other resource owned by the instance.

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.

After the assignment, the value may be eligible for collection, but eligibility is not the same as immediate reclamation. The engine chooses when to collect based on its own heuristics, runtime pressure, and implementation details. Code that needs timely cleanup must perform that cleanup explicitly rather than waiting for the collector.

Deep Debugging: Retaining Paths and Leak Reproduction

When investigating a suspected leak, create a repeatable scenario instead of relying on a single reading. For 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 large object may be temporary and valid. A smaller object retained through an unintended global listener may reveal the actual ownership bug.

A retaining path might look conceptually like this:

text
Window
→ global controllers
→ old ModalController
→ clickHandler closure
→ detached modal element

Read the path from a root toward the retained object. Each link tells you what is keeping the next value alive. Fix the ownership boundary, such as unregistering the handler or destroying the controller, rather than manually nulling random values. Nulling one reference can hide a symptom while leaving another retaining path intact.

FinalizationRegistry warning

JavaScript provides WeakRef and FinalizationRegistry, but they are advanced tools with nondeterministic timing. They can help with specialized observation or bookkeeping, but 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. It is therefore unsuitable for closing a file, removing a required listener, releasing a lock, or performing any other action the program must complete. Lifecycle cleanup should remain explicit whenever correctness depends on it.

Mistakes and Debugging

Common mistakes include:

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

When debugging, start by identifying the operation that repeats and then inspect the references that survive its teardown. Check the browser's event listeners, timer ownership, cache sizes, detached DOM nodes, and heap retaining paths rather than looking only at object size.

Best Practices

Use explicit ownership and lifecycle rules:

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

These practices work together. A bounded cache addresses intentional retention, while teardown addresses resources whose owner has disappeared. DevTools measurements help distinguish a real accumulation pattern from normal temporary memory use.

Exercises

Core

Explain why object = null is not equivalent to "free memory now." Include the difference between removing one reference, becoming unreachable, and being collected.

Practice

Refactor a component with a window event listener so it exposes destroy(). The method should remove the same handler that was registered, and calling it should prevent the component from retaining resources through that listener.

Professional Extension

Build a bounded cache that stores at most 100 entries and evicts the oldest entry when capacity is exceeded. Decide how you will represent insertion order, and verify what happens when an existing key is remembered again.

Recap

JavaScript manages memory automatically, but developers still control reachability. The collector can reclaim values only after they are no longer reachable, and it does not promise to do so immediately. Most memory leaks are really lifetime-management mistakes: a listener, timer, cache, closure, controller, or DOM reference outlives the work that created it. Define ownership, clean up at the ownership boundary, and use retaining paths to find what still keeps unexpected objects alive.

Reader page: /javascript/lesson/084/memory-management-and-garbage-collection