JavaScript memory feels free. There's no delete or free() to remember. The garbage collector handles it. Right?

Mostly. But "mostly" is doing heavy lifting in that sentence. The GC can only collect what's unreachable. If your code keeps a reference to something — even an obscure one — it stays. And the place that reference is hiding is rarely where you'd think to look.

Garbage Collection Follows Reachability

V8 (the engine in Chrome and Node) uses a reachability-based GC. Roots include the global object, the call stack, and CPU registers. Anything reachable from a root through references is kept. Anything else is collected.

That sounds simple. The trick is that "reachable" includes:

  1. Global variables and properties on window / globalThis
  2. Variables captured by closures still in scope
  3. Items in still-alive arrays, maps, sets
  4. DOM nodes referenced from JavaScript
  5. Event listeners attached to those DOM nodes
  6. Timers (setInterval, setTimeout) that haven't fired yet
  7. Subscriptions, observers, promises waiting to resolve

Any one of those can keep a 100MB object graph alive when you thought it was dead.

Leaks Are Usually Forgotten References

The classic leak pattern in modern apps:

JavaScript
const cache = new Map();

function loadUser(id) {
  if (!cache.has(id)) {
    cache.set(id, fetch(`/api/users/${id}`).then(r => r.json()));
  }
  return cache.get(id);
}

Looks fine. Cache is fast, requests are deduplicated. Three months later the page has been open in someone's tab for a week, the cache holds 50,000 user objects, the tab uses 2GB of memory.

The cache is a root. Everything in it is reachable. The GC will never collect it because nothing has told the GC it's safe to.

Two fixes:

JavaScript
// 1. WeakMap — keys can be GC'd if no other reference holds them
const cache = new WeakMap();

// 2. LRU with size limit
class LRU {
  constructor(max) { this.max = max; this.map = new Map(); }
  get(k) { /* refresh */ }
  set(k, v) { if (this.map.size >= this.max) this.map.delete(this.map.keys().next().value); this.map.set(k, v); }
}

WeakMap and WeakSet are the under-used part of the JavaScript memory toolkit — references that don't prevent GC.

Detached DOM Nodes Are Classic Trouble

A "detached" DOM node is one that's no longer in the document but is still referenced from JavaScript. Memory profilers flag these as a leak because they're typically a bug.

JavaScript
// 🐛 leak: button removed from DOM, but listener still references closure
const btn = document.createElement('button');
const largeData = new Array(100_000).fill('x');
btn.addEventListener('click', () => console.log(largeData.length));
document.body.appendChild(btn);
// ...
btn.remove(); // DOM gone, but `btn` is still in scope, listener still attached, largeData still alive

The fix is removing the listener (or using AbortController). The pattern shows up in single-page apps when components mount/unmount many times — every unmount that doesn't clean up its listeners leaks one closure's worth of state.

Reachability graph rooted at the global window object. Solid green arrows trace live references the GC keeps — app state, listeners, cache. Dashed red branches show forgotten closures and detached DOM nodes that look reachable but are leaks waiting to be diagnosed.
Reachability decides everything. A leak is almost always a forgotten reference.

Caches Need Size And Lifetime

Any in-memory cache without bounds is a future memory bug. Three rules:

  1. Cap by size. Use an LRU or capped Map.
  2. Cap by time. Stale entries expire automatically.
  3. Cap by usage pattern. A cache for one user session shouldn't outlive that session.
JavaScript
// time-based cache with capped entries
class TtlCache {
  constructor(maxEntries = 1000, ttlMs = 5 * 60_000) {
    this.max = maxEntries;
    this.ttl = ttlMs;
    this.map = new Map();
  }
  set(k, v) {
    if (this.map.size >= this.max) this.map.delete(this.map.keys().next().value);
    this.map.set(k, { v, exp: Date.now() + this.ttl });
  }
  get(k) {
    const e = this.map.get(k);
    if (!e) return undefined;
    if (Date.now() > e.exp) { this.map.delete(k); return undefined; }
    return e.v;
  }
}

Boring, predictable, free of mysterious bloat. The cache pays back its discipline.

How To Find Real Leaks

Chrome DevTools Memory tab is the answer. Two techniques cover most cases:

  1. Heap snapshot diff: take a snapshot, perform the suspect action, take another. Compare. Objects that grew without good reason are your leaks.
  2. Performance trace with memory enabled: shows allocation rate over time. A sawtooth pattern (allocate, GC, allocate, GC) is healthy. A staircase climbing without dropping is a leak.

The "Detached HTMLDivElement" or similar in the comparison view is almost always a leak — a DOM node that was removed from the document but is still referenced.

Pro Tips

  1. Use WeakMap/WeakSet for caches keyed by objects — let GC do its job.
  2. Cap every cache. Time, size, or both.
  3. Always pair listeners with cleanup. AbortController makes it one line.
  4. Take heap snapshots. Don't guess about memory.
  5. Measure long-running pages. Single-page apps and Node services are where leaks compound.

Final Tips

JavaScript memory is one of those topics that doesn't matter until it suddenly matters a lot. The moment users complain about a slow tab, or a Node service starts OOM-killing, or a React app reloads itself in production — you'll wish you'd been thinking about reachability earlier.

The shortcut: anything you allocate, ask "what holds this alive?" If the answer is "I'm not sure," that's the next leak.

Good luck — and may your heap snapshots stay flat 👊