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 referencesExplain why an object stays as long as at least one reference still points at it.
- 02Spot the loop problemShow why two objects that point at each other keep their counts above 0, even when no variable points at them.
- 03Compare ways to free memoryCompare pure counting, counting with a cycle collector, and tracing from the roots, knowing that JavaScript can't read the counts.
The simple counting idea
Every object your code makes uses some memory. When no code can use the object any more, the engine can free that memory. To free memory means to give it back, so it can be used again. The engine is the program that runs your JavaScript, like V8 in Chrome and Node.
A reference is a way for code to reach an object. Each of these can hold a reference:
- a variable
- a property of another object
- an entry in an array
- a function that still uses a variable from outside it (a closure)
Reference counting means keeping a count for each object: how many references point at it. A new reference adds 1. A removed reference takes away 1. When the count reaches 0, nothing points at the object any more, so it can be freed.
This lesson follows Why garbage collection?. That lesson used roots: the starting places that are always kept, such as global variables and the variables of functions that are still running. A collector that starts at the roots and follows references is called a tracing collector.
Reference counting works in a different way. It asks only one question: how many references point at this object?
Engines keep their counts hidden. JavaScript has no way to read them, and DevTools does not show them. So the counts on this page come from small models: short programs made for this lesson that copy the idea.
Counting has one nice feature. An object can be freed the moment its last reference goes away. Later in the lesson you will see a case where this simple rule fails, and what engines do about it.
How reference counting works
let user = { name: "Asha" };let admin = user;user = null;console.log(admin.name);Here is the count after each line:
- Line 1 makes the object. Only
userpoints at it, so the count is 1. - Line 2 copies the reference into
admin. Now two names point at the same object, so the count is 2. A second name for the same object is called an alias. - Line 3 sets
usertonull. That removes one reference, so the count is 1. - Line 4 prints
Asha, becauseadminstill points at the object.
user holds null, but admin still points at the object. One reference is left, so the count is 1 and the object stays.Line 3 did not delete the object. It did not change its name either. It only changed what the user variable holds. The admin variable still holds the same reference as before.
function readTea() { const order = { drink: "tea" }; console.log(order.drink);} readTea();console.log("function finished");- Line 2 makes an
orderobject inside the function. Only the nameorderpoints at it. - Line 3 reads it and prints
tea. - Line 6 calls
readTea. When the call ends, the nameorderis gone. - Line 7 prints
function finished.
In a counting engine, the end of the call removes the object's only reference. Its count drops to 0, so it can be freed.
This does not mean a real JavaScript engine frees the memory at that exact moment. It shows why counting is attractive: when the last reference goes, the object can go too.
Think of an object as a balloon. Each string tied to it is one reference. Many people can each hold a string. When one person lets go, the others still hold the balloon. When nobody holds a string, the balloon floats 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 are left
- In JavaScript: A count of 0, so the object can be freed
Extra names 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"));This model keeps its counts in a Map. A Map stores pairs of a key and a value. Here the key is the name "user", and the value is its count.
- Line 1 starts the count for
"user"at 1. - Lines 3 to 6 make
retain. It adds 1 to the count. You call it when a new name points at the object. - Lines 8 to 11 make
release. It takes away 1. You call it when a name stops pointing at the object. - Line 13 calls
retainand prints2. - Line 14 calls
releaseand prints1.
On lines 4 and 9, counts.get(name) ?? 0 means "the stored count, or 0 if there is none yet".
A real engine keeps the count with the object itself, not under a text name like "user". The model uses a name only so you can watch the number change.
Each step runs the lesson's real counter functions. It is a model, not a look inside a JavaScript engine.
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 makes
cart. Line 2 makes an empty array,shelf. - Line 4 puts the same cart object into
shelf. Now the array holds a reference too, so the cart has two references. Line 5 prints1, the length of the array. - Line 7 takes that entry out with
pop(). Line 8 prints0, because the array is empty again.
After pop(), the cart still has its cart variable. Removing one array entry removes one reference, not every reference.
Loops that counting never frees
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 make two objects, Asha and Ravi.
- Line 3 gives Asha a
friendproperty that points at Ravi. - Line 4 gives Ravi a
friendproperty that points back at Asha. - Lines 5 and 6 set both variables to
null. - Line 7 prints
outside references removed.
Now no variable points at Asha or Ravi. Your code can never use them again. But each one still has one reference: the friend property of the other one.
This is the cycle problem. A cycle is a loop of references that leads back to where it started. Asha points to Ravi, and Ravi points back to Asha. Each count stays at 1, so pure counting never frees them. "Pure" counting means counting and nothing else.
Objects that no variable or root can reach any more are called detached. They are cut off from the rest of the program.
let order = { name: "order" };order.next = { name: "receipt" };order.next.next = { name: "coupon" };console.log("chain attached"); order = null;console.log("head released");- Line 1 makes an order object and keeps it in the variable
order. - Line 2 puts a receipt object inside the order, as
order.next. - Line 3 puts a coupon object inside the receipt, as
order.next.next. - Line 4 prints
chain attached.
Each object now has exactly one reference. The variable points at the order. The order points at the receipt. The receipt points at the coupon. The first object of a chain is called its head.
Line 6 sets order to null, and line 7 prints head released. Here is what a counting engine does next:
- The order's count drops to 0, so the order is freed.
- Freeing the order removes its reference to the receipt. The receipt's count drops to 0, so the receipt is freed.
- Freeing the receipt removes its reference to the coupon. The coupon is freed too.
One freed object leads to the next. This is called a cascade. It works because the chain has a start and no reference leads back. In the Asha and Ravi loop, no count ever drops to 0, so no cascade can start.
How QuickJS frees loops
QuickJS is a small JavaScript engine. It uses reference counting, so most objects are freed the moment their count reaches 0. To handle loops, it adds a second tool: a cycle collector.
Here is the idea, in three steps:
- Pick a group of objects that might only point at each other.
- For each object, take its count. Subtract the references that come from inside the group.
- If every object has 0 references left from outside, nothing else uses the group. Free the whole group.
The word outside matters. Asha's count is 1. That one reference comes from Ravi, who is inside the pair. So 1 minus 1 leaves 0 references from outside. Ravi is the same.
count, and subtract the links from inside the pair, inside. Both answers are 0, so nothing outside uses the pair and both can be freed.The code below is a simple model made for this lesson. It is not QuickJS's own code. The real engine also has to find the groups, and it has to do this safely while the program keeps running. The model shows one idea: a reference from inside a closed group does not prove that anything outside still uses it.
A model of the cycle collector idea. QuickJS counts references and adds a cycle collector. This short model only shows why subtracting the inside references 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, 0Asha's and Ravi's counts, after taking away the references from inside the pair.
asha, raviThe pair can be freed only when nothing outside points at it.
Both objects have 0 references from outside. Nothing else uses the pair, so the model can free it.
An old Internet Explorer leak
This section is history. A leak is memory that is never freed, even though nothing needs it any more. Old Internet Explorer had two different collectors:
- Objects on the page, such as a button, were freed by reference counting. These page objects are called the DOM.
- JavaScript objects and functions were freed by a different collector that used tracing.
Neither collector could see the whole loop. So a loop that went from a button to a JavaScript function and back was never freed.
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");- Line 1 makes a button. Line 2 makes a
detailsobject. - Line 4 stores a function on the button. It runs when someone clicks the button. A function like this is called a handler.
- The handler uses
buttonanddetails, so it keeps references to them. The button keeps a reference to the handler. That makes a loop from the page side to the JavaScript side and back.
The fix back then was to clean up by hand. Line 8 removes the handler. Line 9 removes the button from the page. Line 10 prints handler removed.
Modern browsers trace page objects and JavaScript objects together, so this code does not leak for that reason today. Removing handlers when you remove part of a page is still a good habit. It matters most on a page that stays open for a long time.
Why most engines use tracing
The table compares the three ways to free objects from this lesson. Each row is one way. Read across to see what work it does and what happens to a detached loop.
| Which way | What work it does | What happens to a detached loop |
|---|---|---|
| Pure reference counting | It changes a count each time a reference is added or removed. | It frees an object the moment its count reaches 0. But a detached loop keeps its counts above 0, so it is never freed. |
| Reference counting with a cycle collector | It counts as usual. It also checks groups that might only point at each other. | It still frees objects right away at 0. The cycle collector frees detached loops too. |
| Tracing | It starts at the roots and follows references. | No path from the roots reaches a detached loop, so it is freed. |
Counting has some costs:
- Many assignments need a count change.
- Each count takes up a little memory.
- Loops still need a second tool, like a cycle collector.
A tracing collector does not change any counts when your code assigns. It does its work later, when it collects. It starts at the roots and follows references. A detached loop has no path from the roots, so tracing frees it with no extra tool.
So the two ways do their work at different times:
- Counting works on every assignment. Copying a reference adds 1 to a count. Overwriting or removing a reference takes 1 away.
- Tracing leaves assignments alone. Its work happens later, in one collection step.
const assignments = 3;const countUpdates = assignments * 2; console.log("count updates", countUpdates);console.log("trace later", "walk from roots");- Line 1 says there are three assignments.
- Line 2 counts two count changes for each assignment: one count goes down for the old value, and one goes up for the new value.
- Line 4 prints
count updates 6. - Line 5 prints
trace later walk from roots. Tracing is not free, but its main work happens later, during collection.
Neither way is best for everything. Reference counting frees simple objects right away, and some engines use it. Tracing frees loops with no extra tool. That is why most modern JavaScript engines are built on tracing.
A test shows V8 frees a detached loop
This program runs only in Node, not on this page. It uses two tools you may not have seen:
- A
WeakRefis a reference that does not keep its object alive. Itsderef()method gives back the object, orundefinedonce the object has been freed. globalThis.gc()asks V8 to collect right now. It only exists when Node starts with the--expose-gcoption.
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");- Line 1 makes
wait. It pauses until other waiting work has had a turn. - Lines 3 to 11 make the Asha and Ravi loop inside a function. They keep a
WeakRefto Asha, then set both variables tonull. - Lines 15 to 23 try up to 8 times. Each time, they wait, ask V8 to collect, wait again, and check whether the
WeakRefis now empty. - Line 24 prints whether the loop was freed.
This lesson's test runs the program in Node 22 with --expose-gc. After a few tries, V8 frees the loop and the WeakRef comes back empty. This shows that V8 uses tracing. It does not tell you when objects in a real app will be freed.
To use weak references in your own code, see WeakRef and finalization.
What this means for your code
In JavaScript, you never add or remove reference counts yourself. To keep memory under control:
- Know which part of your code owns each object.
- Remove event handlers you no longer need.
- Put a size limit on caches. A cache is a store where you keep results to use again.
- When memory keeps growing, take a heap snapshot in DevTools. It lists every object in memory. For each object, it shows the retaining path: the chain of references from a root that keeps the object alive.
- The last name that points at an object is removed.
- Asha and Ravi only point at each other.
- Add 1 to a number when a second name points at an object.
- Start from global variables and the variables of running functions.
- One object has no references left.
- Subtract the references from inside a group of objects.
Sort each card by the way of freeing that handles it.
Common misconceptions
- “Setting a variable to null frees the object.” It removes one reference. Another name may still point at the object.
- “Loops of objects always leak.” Tracing collectors free loops that are detached.
- “I can read an object's count.” JavaScript has no way to read a reference count.
- “The old Internet Explorer leak explains leaks today.” It was a problem in old browsers. Modern browsers do not behave that way.
Each row of the table below starts with something you might see. Read across to see what it means and what it does not mean.
| When you see | What it means | What it does not mean |
|---|---|---|
user = null | One reference was removed. | It does not mean the object is freed right then. |
| A loop of objects | The objects point at each other. | It does not mean every modern JavaScript engine leaks it. |
| A reference count | A number the engine keeps for each object. | It is not a number your JavaScript code can read. |
| The old Internet Explorer leak | A loop between page objects and JavaScript that old browsers never freed. | It does not mean the same code leaks in browsers today. |
Practice exercises
What does this program print? Type the output.
let user = { name: "Asha" };
let admin = user;
user = null;
console.log(admin.name);It prints Asha. Only user was set to null. admin still points at the object.
What does this program print? 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. Line 2 adds 1 for a new name. Line 3 takes it away again.
Asha and Ravi point at each other, and no variable points at them. Why does pure counting keep them?
Each object has a count of one, from the other object. So pure counting never reaches 0.
A cycle collector looks at a group of objects. What should it subtract from each count before it decides to free the group?
Subtract the internal references, the ones from inside the group. If no references from outside are left, the group can be freed.
A dashboard page uses more and more memory the longer it stays open. What should you look at first?
Take a heap snapshot and look at the retaining path: the chain of references that keeps the object alive. Then change the app.
Check your understanding
Question 1 of 7What does pure reference counting do when an object's count reaches 0?
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 does pure reference counting never free two objects that point at each other, once no variable points at them?
Choose an answer to see the explanation.
Question 4 of 7What 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 use tracing?
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 keeps a count of references for each object. It frees the object when the count reaches 0.
- A new name adds 1 to the count. Removing a name takes 1 away. JavaScript cannot read the count.
- A detached loop defeats pure counting. The references inside the loop keep every count above 0.
- QuickJS adds a cycle collector. Most JavaScript engines use tracing from the roots instead.
- The old Internet Explorer leak is history. Modern browsers trace page objects and JavaScript objects together.
Remember the one-liner.
A counter only sees how many references point at an object; a tracing collector asks whether any path from the roots reaches it.
Coming next: Mark-sweep & mark-compact, a closer look at how tracing collectors work.