Reference counting & cycles
Learn how reference counting frees objects, why cycles defeat it, how QuickJS repairs that gap, and why JavaScript engines trace instead.
- 01Count aliasesExplain why an object stays alive while at least one reference still points to it.
- 02Spot the cycle problemShow why two detached objects can keep each other's pure reference count above zero.
- 03Compare collector choicesContrast counting, cycle collection, and tracing without assuming JavaScript exposes engine internals.
The simple counting idea
Reference counting is a memory strategy that keeps a number for each object: how many references currently point at it. When the number reaches zero, the object can be freed.
Reference counting means keeping a count of the references that point to an object. Adding a reference increases that count, removing one decreases it, and a count of zero means the object has no remaining owner in this model.
This follows Why garbage collection?. That lesson used roots and tracing. Here, start with the simpler question: how many references point at one object?
A reference is a value that lets code reach an object. A variable, an object property, an array entry, or a closure can hold one. Engines keep this bookkeeping private, so the counts on this page are a teaching model rather than an API you can inspect in DevTools.
Counting has one appealing property: an object can be freed as soon as its last reference disappears. That immediate result is easy to picture. The rest of the lesson asks why that simple rule is not enough for every object graph.
How reference counting works
let user = { name: "Asha" };let admin = user;user = null;console.log(admin.name);Line 1 creates the object, so its teaching count is 1. Line 2 makes admin another reference, so the count is 2. Line 3 removes user, leaving count 1. Line 4 prints Asha because admin still points at the object.
Notice what did not happen on line 3: JavaScript did not erase the object or change its name. It only changed which value the user variable holds. The separate admin variable still has the original object value.
function readTea() { const order = { drink: "tea" }; console.log(order.drink);} readTea();console.log("function finished");Line 2 creates an order object and line 3 reads it, so the program prints tea. When line 6 runs, readTea has returned and its local name is gone. In a counting design, that local reference would be released; if it was the last one, the object could be freed.
This is not a promise that a JavaScript engine frees memory exactly when a function returns. It is the clean ownership story that makes reference counting attractive: remove the last string, then release the balloon.
Think of an object as a balloon. Each string tied to it is one reference. When nobody holds any string, the balloon can float away.
- In real life: A string tied to a balloon
- In JavaScript: A reference to an object
- In real life: How many strings are tied
- In JavaScript: The reference count
- In real life: No strings remain
- In JavaScript: Count zero frees the object
Where the analogy stops: Real references are invisible pointers, and JavaScript never shows you a count.
Aliases add and remove references
const counts = new Map([["user", 1]]); function retain(name) { counts.set(name, (counts.get(name) ?? 0) + 1); return counts.get(name);} function release(name) { counts.set(name, (counts.get(name) ?? 0) - 1); return counts.get(name);} console.log(retain("user"));console.log(release("user"));Line 1 starts user at 1. Lines 3 to 5 add one for a new alias. Lines 8 to 10 remove one when an alias goes away. The code prints 2, then 1.
The Map stores a name and a number only so the change is visible. A real engine associates bookkeeping with an object, not the text user. The model is useful because it makes the two ordinary operations explicit: retain when an alias arrives, release when it leaves.
Replay an instrumented teaching model. It records real calls to this lesson's counter, not a JavaScript engine debugger.
script
function retain(name) { counts.set(name, (counts.get(name) ?? 0) + 1); return counts.get(name);} function release(name) { counts.set(name, (counts.get(name) ?? 0) - 1); return counts.get(name);} console.log(retain("user"));console.log(release("user"));const cart = { items: ["tea"] };const shelf = []; shelf.push(cart);console.log(shelf.length); shelf.pop();console.log(shelf.length);Line 1 creates cart. Line 4 puts that same object into shelf, so the array has another reference and line 5 prints 1. Line 7 removes the entry, and line 8 prints 0 because the array is empty again.
The cart still has its variable reference after pop(). This is the same alias rule in a more realistic form: removing one array entry removes one route to an object, not every route.
Cycles that never free
let asha = { name: "Asha" };let ravi = { name: "Ravi" };asha.friend = ravi;ravi.friend = asha;asha = null;ravi = null;console.log("outside references removed");Lines 1 and 2 create Asha and Ravi. Lines 3 and 4 make them point at each other. Lines 5 and 6 remove the outside names. The code prints outside references removed, but a pure counter still sees one reference into each object: the other object.
That is the cycle problem. Nobody outside can use either object, yet neither count reaches zero.
A cycle is a group of references that eventually leads back to where it started. Here Asha points to Ravi and Ravi points to Asha. After the two outside variables are cleared, the pair is detached from the program but each object still receives one internal reference.
const order = { next: null };const receipt = { next: null };const coupon = { next: null }; order.next = receipt;receipt.next = coupon;console.log("chain attached"); order.next = null;console.log("head released");Lines 1 to 3 make three objects. Lines 5 and 6 link them as order, receipt, then coupon, and line 7 prints chain attached. When line 9 removes the head link, a counting collector can release the order; releasing it removes the receipt's only incoming link, then the coupon's. That is a cascade.
A chain differs from the Asha/Ravi pair because its arrows do not loop back. Once the head reaches zero, each later object can also reach zero. In a cycle, releasing one object never begins because both counts are stuck at one.
QuickJS and cycle collection
QuickJS uses reference counting for predictable freeing and adds a cycle collector. It looks for a group that may only be referenced by itself, subtracts links inside that group, and collects the group when no outside references remain.
The word outside matters. The collector first suspects a group, then asks which references enter it from somewhere else. It temporarily ignores links that stay inside the group. Asha has one total incoming link and one link from Ravi, so its outside count is zero in the two-object example.
This is a simplified teaching model, not QuickJS source code. The real engine must find candidate groups and do the work safely while programs create and remove values. The model keeps one idea visible: a link within a closed group is not proof that an outside owner exists.
Replay a teaching model of cycle collection. QuickJS uses reference counting plus a cycle collector; this simplified model only illustrates why subtracting internal links helps.
script
asha: { count: 1, inside: 1 }, ravi: { count: 1, inside: 1 },}; function outsideCount(item) { return item.count - item.inside;} console.log(outsideCount(group.asha), outsideCount(group.ravi));const group = { asha: { count: 1, inside: 1 }, ravi: { count: 1, inside: 1 },}; function outsideCount(item) { return item.count - item.inside;} console.log(outsideCount(group.asha), outsideCount(group.ravi));0, 0References inside the pair have been subtracted.
asha, raviThe group is collectible only when it has no outside owner.
Both outside counts are zero, so this teaching model can collect the detached group.
A historical Internet Explorer leak
const button = document.createElement("button");
const details = { order: "tea" };
button.onclick = function showOrder() {
button.title = details.order;
};
button.onclick = null;
button.remove();
console.log("handler removed");This is historical. Old Internet Explorer used reference counting for DOM objects while JavaScript objects were traced by a different collector. A DOM element and a closure could make a cycle across those two systems, so neither system freed it.
Step by step, line 1 creates a DOM button and line 2 creates data. Line 4 stores a handler on the button. The handler can read button and details, so the DOM side and JavaScript side keep a path to each other. The old collectors did not agree on how to break that cross-system cycle.
The historical fix was explicit cleanup. Line 7 removes the handler and line 8 removes the element; line 9 prints handler removed. Modern browsers use a unified tracing approach across DOM and JavaScript, but removing listeners when UI is removed is still good application ownership, especially when a long-lived page retains them.
Modern browsers trace across DOM and JavaScript objects, so this code does not leak for that historical reason today.
Why most engines trace instead
| Approach | Main work | Cycle result |
|---|---|---|
| Pure reference counting | Update a count on every new or removed reference | Frees immediately at zero, but a detached cycle stays above zero. |
| Reference counting plus cycle collection | Count normally, then inspect groups that may only point inside themselves | Keeps predictable freeing and repairs the cycle gap. |
| Tracing collection | Start at roots and follow references | Detached cycles have no root path, so they are naturally garbage. |
Every assignment can need a count update. Counts use extra memory. Cycles need a second algorithm anyway. Tracing does work at collection time, but detached cycles naturally have no path from roots.
A counter pays small work on ordinary mutations. Copying a reference usually increments one count; overwriting or removing it usually decrements one. A tracing collector leaves those assignments mostly alone, then later walks outward from roots during collection. The work moves from each assignment to a collection phase.
const assignments = 3;const countUpdates = assignments * 2; console.log("count updates", countUpdates);console.log("trace later", "walk from roots");Line 1 chooses three assignment events. Line 2 models two count changes per event and line 4 prints count updates 6. Line 5 prints trace later walk from roots: a trace is not free, but its main graph walk happens during collection instead.
Neither approach is automatically best. Reference counting can free simple objects promptly and is useful in some runtimes. Tracing naturally handles cycles, which is why it is the usual foundation for modern JavaScript engines.
V8 collects a detached cycle
const wait = () => new Promise((resolve) => setTimeout(resolve, 0));
function detachedCycle() {
let asha = { friend: null };
let ravi = { friend: asha };
asha.friend = ravi;
const weak = new WeakRef(asha);
asha = null;
ravi = null;
return weak;
}
const weak = detachedCycle();
let collected = false;
for (let attempt = 0; attempt < 8; attempt += 1) {
await wait();
globalThis.gc();
await wait();
if (weak.deref() === undefined) {
collected = true;
break;
}
}
process.stdout.write("V8 tracing cycle collected " + collected + "\n");The test runs this in a Node 22 child process with --expose-gc. After later-task collection retries, V8 clears the WeakRef to the detached cycle. That is evidence for tracing, not a timing promise for applications.
For weak references in application code, use WeakRef and finalization.
Practical developer habits
JavaScript developers do not add or remove reference counts themselves. Keep ownership clear, remove unused listeners, bound caches, and inspect retaining paths when memory grows.
- The last outside alias is removed.
- Asha and Ravi only point at each other.
- Increment a number when an alias is created.
- Start from globals and stack locals.
- One object has no references left.
- Subtract links that stay inside one suspected group.
Sort each card by the idea that explains it.
Common misconceptions
- “Null frees an object.” It removes one reference; another alias may remain.
- “Cycles always leak.” Tracing collectors collect detached cycles.
- “I can read the count.” JavaScript exposes no reference-count API.
- “Old IE explains modern browser leaks.” It is a historical case, not current browser behavior.
| Thing | It means | It does not mean |
|---|---|---|
user = null | One reference was removed | The object is definitely freed right then. |
| A cycle | Objects point at each other | Every modern JavaScript engine leaks it. |
| Reference count | An engine bookkeeping number | A number JavaScript code can read. |
| Historical IE leak | An old cross-collector cycle problem | A leak in current browsers from this code alone. |
Practice exercises
Type the output.
let user = { name: "Asha" };
let admin = user;
user = null;
console.log(admin.name);It prints Asha because admin still references the object.
Type the output.
const counts = new Map([["tea", 1]]);
counts.set("tea", counts.get("tea") + 1);
counts.set("tea", counts.get("tea") - 1);
console.log(counts.get("tea"));It prints 1. The temporary alias was added and then removed.
Why does pure counting keep a detached Asha/Ravi pair?
Each object has a count of one from the other object, so pure counting does not reach zero.
What should a cycle collector subtract before deciding whether to free the group?
Subtract internal references. A group with no outside references is garbage.
A dashboard keeps growing. What should you inspect first?
Inspect a retaining path in a heap snapshot before changing the application.
Check your understanding
Question 1 of 7What does a pure reference counter do when a count reaches zero?
Choose an answer to see the explanation.
Question 2 of 7What does this print?
Read the code, then predictlet user = { name: "Asha" }; let admin = user; user = null; console.log(admin.name);Choose an answer to see the explanation.
Question 3 of 7Why do two detached objects defeat pure reference counting?
Choose an answer to see the explanation.
Question 4 of 7What historical problem did old Internet Explorer have?
Choose an answer to see the explanation.
Question 5 of 7What does QuickJS add to reference counting?
Choose an answer to see the explanation.
Question 6 of 7Why do most JavaScript engines trace instead?
Choose an answer to see the explanation.
Question 7 of 7What does this count model print?
Read the code, then predictconst counts = new Map([["cart", 1]]); counts.set("cart", counts.get("cart") + 1); counts.set("cart", counts.get("cart") - 1); console.log(counts.get("cart"));Choose an answer to see the explanation.
Key takeaways
- Reference counting frees an object when its count reaches zero.
- Aliases increase or decrease a count, but JavaScript does not expose it.
- A detached cycle defeats pure counting because its internal links keep counts above zero.
- QuickJS adds cycle collection; most JavaScript engines trace from roots instead.
- Old Internet Explorer DOM cycles are historical; modern browsers trace across both sides.
Remember the one-liner.
A pure reference counter sees links; a tracing collector sees whether roots can reach an object.
Coming next: Mark-sweep & mark-compact.