Memory leaks in JavaScript — common causes and how to spot them

JavaScript has automatic garbage collection, but that only frees memory nothing can reach anymore — a "leak" in JS almost always means something is still holding a reference to data that should have been forgotten, not that garbage collection failed.

Advanced

4 min read

Garbage collection frees unreachable memory — a leak means something is still reachable

let data = { huge: new Array(1_000_000).fill("x") };
data = null; // the ORIGINAL object is now unreachable — eligible for garbage collection

JavaScript's garbage collector automatically frees memory for any object that's no longer reachable from anywhere the running program can still access — no manual free() call required, unlike some other languages. Critically, this means a JavaScript "memory leak" almost never means the garbage collector is broken; it means something in the code is still holding a reference to data that logically should have been forgotten, keeping it artificially reachable and therefore un-collectible, even though nothing actually uses it anymore.

Leak cause 1: forgotten event listeners on removed elements

function attachHandler(item) {
  const heavyData = new Array(100_000).fill(item.id); // captured by the closure below
  item.element.addEventListener("click", () => {
    console.log(heavyData.length); // this closure keeps heavyData ALIVE for as long as the listener exists
  });
}
// If item.element is later removed from the DOM WITHOUT removeEventListener(),
// the listener (and everything it closes over, including heavyData) can still
// be kept alive by the browser's internal event system, even though the element is gone

An event listener attached to a DOM element is a real reference, and if that element is removed from the page without also calling removeEventListener(), some browsers/situations keep the listener (and its closure, which the earlier closures lesson covered as keeping everything it references alive) around longer than expected — a real, common source of gradually accumulating memory in a long-running single-page application where components mount and unmount repeatedly without cleaning up their own listeners.

Leak cause 2: timers that are never cleared

class LiveTicker {
  constructor() {
    this.intervalId = setInterval(() => this.updatePrice(), 1000); // keeps `this` alive as long as it runs
  }
  destroy() {
    clearInterval(this.intervalId); // WITHOUT this call, the interval (and `this`) runs forever
  }
}

A setInterval that's never cleared keeps running — and because its callback closure holds a reference to this (the entire LiveTicker instance), that instance can never be garbage collected as long as the interval is active, even if every other part of the application has completely stopped referencing it. This is a genuinely common real bug in component-based frameworks: a component sets up a timer when it mounts but forgets to clear it when it unmounts, and the timer (plus everything its closure captured) leaks for the remaining lifetime of the page.

Leak cause 3: an ever-growing cache or array with no eviction

const cache = new Map();
function memoize(key, computeFn) {
  if (!cache.has(key)) cache.set(key, computeFn()); // NEVER removes old entries — grows forever
  return cache.get(key);
}

A cache that only ever adds entries and never removes old, unused ones will grow without bound for the lifetime of the program — not a bug in the traditional sense (every entry is genuinely reachable, so garbage collection is working correctly), but a real, practical memory problem all the same. The fix is a bounded cache with an eviction policy (LRU — evict the Least Recently Used entry when a size limit is hit — is a common, real strategy), or a WeakMap when the cache's keys are objects whose own lifetime should govern the cache entry's lifetime automatically.

WeakMap/WeakSet: references that don't prevent garbage collection

const cache = new WeakMap(); // keys must be OBJECTS, and hold only a WEAK reference to them
function attachMetadata(element, data) {
  cache.set(element, data);
}
// If `element` is later removed from the DOM and nothing else references it,
// it (and its associated cache entry) becomes eligible for garbage collection
// automatically — a REGULAR Map would keep both alive forever, since a Map's
// keys are normal, strong references

A WeakMap's keys are held with a weak reference — one that doesn't, by itself, keep the referenced object alive for garbage-collection purposes — so when an object used as a WeakMap key becomes otherwise unreachable, its entry is automatically removed too, with no manual cleanup needed. This makes WeakMap (and WeakSet) the right tool specifically for associating extra data with an object (like a DOM element) without that association itself becoming a leak source, unlike a regular Map, whose keys are strong references that keep entries alive indefinitely regardless of whether anything else still needs them.

Further reading

Check your understanding

A quick comprehension check — not tracked, not graded, just for you.

1. What does a JavaScript 'memory leak' actually mean, given that JS has automatic garbage collection?

2. Why does an uncleared `setInterval` inside a class method cause a memory leak?

3. What makes a `WeakMap` different from a regular `Map` for avoiding memory leaks?