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.
Step-by-Step Fix
- Force a collection, then capture. DevTools path: Memory → collect garbage → Heap snapshot. Expected: a cycle that was never reachable is already gone.
- If it survives, open Retainers. Expected: a path from a root that has nothing to do with the cycle’s internal links.
- Name the root. Expected: a module-scope collection, a listener, a timer or a cache — the same four holders behind most retention.
- Remove the root’s reference. Verification: every member of the cycle disappears from the next snapshot, not just one.
- 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.
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.
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.
Related
- Reference Counting vs Tracing GC Algorithms — why engines chose tracing in the first place
- Does JavaScript Use Reference Counting for Garbage Collection? — the cycle that breaks counting, in detail
- Interpreting Heap Snapshots for Memory Analysis — finding the root that actually holds the cycle