← All posts

Hunting JavaScript memory leaks with V8 mechanics and Chrome DevTools

How V8 decides what stays alive, seven common leak patterns, and how to find the retaining path with Performance and heap snapshots.

Hunting JavaScript memory leaks with V8 mechanics and Chrome DevTools
Contents

In brief

Modern JavaScript rarely asks you to free memory by hand — the garbage collector cleans up.

Leaks usually are not forgotten allocations; they are references that stay reachable longer than intended.

The source article walks through V8’s heap generations, seven everyday leak patterns, and a Chrome DevTools path: spot the rising heap baseline, then take heap snapshots and follow retainers.

What happened

The author starts from the engine’s rule: an object lives while a chain of references from a GC root can still reach it.

Roots include the global object, the call stack, and active host handles. While that path exists, the data stays in the heap.

Think of a rented flat you have already left — if a friend still holds the key, the place is not free. The same is true for a leftover reference in code.

V8 splits objects by age. The young generation is scavenged often and cheaply; most allocations die young.

Survivors move to the old generation. There, heavier mark-sweep-compact work runs — including concurrent and incremental marking to avoid long stop-the-world pauses.

Generations change the cost of collection, not the reachability rule: an unwanted path from a root keeps the object in the heap.

Then come seven patterns. An undeclared assignment in non-strict mode bleeds into the global object: the function returns, but a large array still hangs off window.

A node is removed with .remove() or .removeChild(), yet an array or cache still holds it — a detached tree. In the author’s demo the node’s shallow size is tiny, while retained size eats most of the heap because of a million-element payload.

A window or document listener without removeEventListener keeps the callback and everything it closed over. The UI is gone, but resize still pulls heavy metadata.

A forgotten setInterval or endless requestAnimationFrame does the same through browser timers. The callback stays reachable until clearInterval or cancelAnimationFrame.

A long-lived closure captures a huge array when it only needed one number: store the size, not the array.

A plain Map used as an unbounded object-keyed cache never lets keys go until .delete(). Prefer WeakMap / WeakSet when keys are objects and you do not need to iterate entries.

Finally, console.log of large structures while DevTools is open can retain objects during a session and inflate heap numbers. Fine while debugging; misleading while profiling.

The shared motif across all seven cases: someone still holds the key after the tenant has left. Tools exist so you can name that holder, not guess from symptoms alone.

Why it matters

A memory leak rarely screams at first: an SPA grows heavier after navigation, a tab eats gigabytes after an hour, a mobile browser kills the process.

Engineers chase CPU and network while the real root is a reference that should have been dropped on unmount or route change.

Thinking in reachability reframes the bug into three questions. Which object is retained? Which root keeps it alive? Where in the lifecycle should that reference have been released?

Without that frame, heap snapshots are a list of class names with no next step. With it, you know what to change — clear a timer, remove a listener, use a weak cache, or stop capturing unused data in a closure.

For full-stack work the same reachability model applies in Node.js on V8.

Global caches, forgotten timers, and never-unsubscribed server event handlers produce the same class of bug as a dangling window listener — only without a browser tab the user can simply close.

In practice

Confirm the signal before you chase a chain — otherwise you “fix” a leak that is not there.

The article’s tooling path is two steps, then lifecycle discipline in code.

Repeat the same user action several times between snapshots — that makes it easier to separate noise from a real leak.

  1. Open the Performance panel, enable Memory, record a suspect flow, and force garbage collection several times (trash icon). A JS heap baseline that climbs in a stair-step after GC is a strong leak signal.
  2. In Memory, take a heap snapshot before and after a repeatable user action; compare objects allocated between snapshots, or use filters such as objects retained by detached nodes.
  3. In Retainers, walk from the object back to a root — a global cache, a listener closure, or a “just in case” array often shows up.
  4. Make teardown explicit in code: clearInterval / cancelAnimationFrame, paired removeEventListener, and drop DOM references after .remove().
  5. Prefer WeakMap for object-keyed metadata when you do not need to iterate keys; put a size limit and eviction on a regular Map.
  6. Avoid logging huge structures to the console while profiling — DevTools retention can lie about heap size.
  7. In non-strict code, shut down global bleed early: const / let and 'use strict' close a whole class of accidental leaks before you open the profiler.

Takeaway

A JavaScript memory leak is almost always extra reachability, not a missing free.

V8 keeps whatever a root can still touch; Chrome DevTools shows the stair-steps and names the retaining path.

Lifecycle discipline — timers, listeners, weak collections — covers most of the everyday cases in the source article.

If the heap baseline still climbs after forced garbage collection, hunt the retaining path first — not an algorithm “optimization.”

Comments

Loading comments…