Do Circular References Leak Memory in JavaScript?

No — and the reason the question survives is worth understanding, because when a cycle genuinely stays in the heap the real cause is something else entirely. This page belongs to reference counting vs tracing GC algorithms inside JavaScript Memory Fundamentals & Runtime Mechanics.

Symptom Root Cause Immediate Action
A cycle appears in every snapshot It is reachable from a root Read the retainer chain, ignore the cycle
Cycle disappears after forcing collection It was never a leak Always measure after a collection
Manual parent = null changed nothing Tracing never counted references Revert the code; find the root instead
A cycle through a native addon survives A strong persistent handle on the native side Release the handle in the addon’s teardown
A DOM-to-JS cycle blamed for growth Modern engines collect these Look for a listener, timer or cache instead

Root Cause: Reachability, Not Counting

A reference-counting collector frees an object when its count reaches zero. Two objects pointing at each other each have a count of one, so neither ever reaches zero — the classic cycle leak. That is a real property of that algorithm and the reason no JavaScript engine uses it as its primary collector.

Every modern engine traces instead. It starts from the roots — the stack, globals, the DOM tree, pending platform work — and marks everything it can reach. Anything unmarked is garbage, whatever its internal reference structure. A cycle with no path from a root is simply unreachable, and unreachable is the only criterion, so it is swept with everything else.

That means a cycle in your snapshot is not evidence of anything by itself. If it is still there after a forced collection, something is reaching it: a module-scope array, a live event listener, a cache entry, an uncleared timer. The cycle is a red herring, and the retainer chain names the actual holder — which is why the productive move is to open the Retainers pane rather than to start nulling out fields.

The historical exception is worth stating once so it can be set aside. In Internet Explorer 6 and 7, the DOM was reference-counted while the script engine traced, and a cycle spanning both was invisible to each. That architecture is long gone. The one live descendant of the problem is a native Node addon holding a strong persistent handle, where the collector genuinely cannot see through the native side of the link.

The same cycle, collected or retained On the left, two objects reference each other with no path from any root; the tracing collector never reaches them and both are swept. On the right, an identical cycle is reachable through a module-scope array, so both members survive — and the array, not the cycle, is the bug. Identical cycles; only the path from a root differs Unreachable — collected parent child no path from any GC root swept at the next major collection Reachable — retained parent child module-scope registry[] — the real bug breaking the cycle here would change nothing

Step-by-Step Fix

  1. Force a collection, then capture. DevTools path: Memory → collect garbage → Heap snapshot. Expected: a cycle that was never reachable is already gone.
  2. If it survives, open Retainers. Expected: a path from a root that has nothing to do with the cycle’s internal links.
  3. Name the root. Expected: a module-scope collection, a listener, a timer or a cache — the same four holders behind most retention.
  4. Remove the root’s reference. Verification: every member of the cycle disappears from the next snapshot, not just one.
  5. Check native boundaries in Node. Expected: if an addon is involved, its persistent handle is the equivalent of the root and must be released in the addon’s own teardown.

Command & Code Reference

Demonstrating that the cycle itself is not the problem, which is worth doing once to settle the question in a team.

// node --expose-gc cycles.js
function makeCycle() {
  const parent = { name: 'parent' };
  const child = { name: 'child', parent };
  parent.child = child;            // a genuine cycle
  return parent;
}

function measure(keepReference) {
  global.gc();
  const before = process.memoryUsage().heapUsed;
  const kept = [];
  for (let i = 0; i < 200_000; i++) {
    const cycle = makeCycle();
    if (keepReference) kept.push(cycle);   // a root now reaches every cycle
  }
  global.gc();
  const after = process.memoryUsage().heapUsed;
  return { keptCount: kept.length, mb: ((after - before) / 1048576).toFixed(1) };
}

console.log('cycles, no reference kept:', measure(false));  // ~0.0 MB
console.log('cycles, reference kept:   ', measure(true));   // ~38 MB

What the fix looks like: remove the root’s reference, not the cycle’s links.

// Pointless: the collector never counted these references.
function teardownBad(node) {
  node.parent.child = null;
  node.parent = null;              // changes nothing about reachability
}

// Effective: drop the reference that a ROOT holds.
const registry = [];               // module scope — a GC root path

function trackNode(node) { registry.push(node); }

function teardownGood(node) {
  const i = registry.indexOf(node);
  if (i !== -1) registry.splice(i, 1);   // now nothing reachable points at it
}

The one case that still leaks: a native handle the collector cannot trace through.

// In an N-API addon, a napi_ref created with a reference count of 1 is a
// STRONG persistent handle. If the JavaScript object also references the
// addon's wrapper, the cycle spans the native boundary and neither side
// releases it — the modern descendant of the old IE problem.
//
//   napi_create_reference(env, jsObject, 1, &ref);   // strong: retains
//   napi_create_reference(env, jsObject, 0, &ref);   // weak: does not
//
// The fix is on the native side: use a weak reference, or release the strong
// one in the wrapper's finalizer.
200 000 cycles, with and without a root Creating two hundred thousand parent-child cycles and dropping every reference retains essentially nothing after a forced collection. Keeping each cycle in a module-scope array retains thirty-eight megabytes. The cycles are identical in both runs. The cycles are identical; only the array differs no reference kept ~0.0 MB retained — every cycle collected kept in a module-scope array 38 MB retained — the array is the leak, not the circularity Run this once in a codebase where the myth persists; it ends the conversation faster than any explanation. Then spend the effort on the retainer chain, where the actual bug always is.

Verification & Regression Prevention

Verify by confirming that every member of the cycle disappears. If one survives, it had a second retainer, and removing the first one only revealed the second — a common and confusing outcome when a structure is referenced from more than one place.

For prevention, the useful habit is not about cycles at all: it is auditing what module-scope structures hold. Registries, caches, arrays of “everything created so far” and listener lists are the roots behind essentially every retained cycle, and each of them needs the same treatment as any other cache — an eviction policy, a teardown, or a weak reference. The cycle is incidental; the root is the design decision.

Four beliefs about cycles, corrected Cycles leak is false in every modern engine. Nulling parent references helps is false and adds risk. A DOM to JavaScript cycle leaks was true only in Internet Explorer 6 and 7. A native addon cycle can leak remains true and is fixed on the native side. Three of these are folklore; one is still real Belief Reality Do instead “Cycles leak” false — tracing collects them find the root “Null out parent links on teardown” no effect, extra risk delete the code “DOM ↔ JS cycles leak” true in IE 6/7 only check listeners instead “Native addon cycles can leak” still true weak refs in the addon The bottom row is the only one worth carrying forward into a code review.

FAQ

Do I need to null out parent and child references manually?

No. Every modern JavaScript engine uses tracing collection, which reclaims unreachable cycles by construction. Manually breaking links adds code, adds risk, and changes nothing — the collector never counted references in the first place.

Then why did older guidance say cycles leak?

Because in Internet Explorer 6 and 7 the DOM used reference counting while the script engine used tracing, and a cycle spanning both was collected by neither. That was a real bug in a specific architecture, fixed a long time ago, and the advice outlived the problem by about fifteen years.

Can a cycle ever leak in Node.js today?

A pure JavaScript cycle cannot. A cycle that crosses into a native addon can, if the addon holds a strong persistent handle to a JavaScript object that also references the addon’s wrapper, because the collector cannot see through the native side of that link.