completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Reference counting & cycles

Learn how reference counting frees objects, why cycles defeat it, how QuickJS repairs that gap, and why JavaScript engines trace instead.

By the end, you can
  • 01
    Count referencesExplain why an object stays as long as at least one reference still points at it.
  • 02
    Spot the loop problemShow why two objects that point at each other keep their counts above 0, even when no variable points at them.
  • 03
    Compare 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)
Definition

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

One object, then a second name for itPop out in the code editor (opens in a new tab)JavaScript
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 user points 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 user to null. That removes one reference, so the count is 1.
  • Line 4 prints Asha, because admin still points at the object.
After line 3. The user tag now holds null and points at nothing. The admin tag still points at the object with name Asha. The object's count is 1.usernow nullcount: 1name: "Asha"objectadminstill points
After line 3, 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.

A function's own object loses its name when the function endsPop out in the code editor (opens in a new tab)JavaScript
function readTea() {  const order = { drink: "tea" };  console.log(order.drink);} readTea();console.log("function finished");
  • Line 2 makes an order object inside the function. Only the name order points at it.
  • Line 3 reads it and prints tea.
  • Line 6 calls readTea. When the call ends, the name order is 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.

Real-life analogyBalloons on strings

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

A tiny model that counts referencesPop out in the code editor (opens in a new tab)JavaScript
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 retain and prints 2.
  • Line 14 calls release and prints 1.

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.

Step through a tiny reference counter
Step 0 of 7Ready
Your turn: follow the blue line

Each step runs the lesson's real counter functions. It is a model, not a look inside a JavaScript engine.

Running in
  1. script
Next: line 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"));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
An array entry is one more referencePop out in the code editor (opens in a new tab)JavaScript
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 prints 1, the length of the array.
  • Line 7 takes that entry out with pop(). Line 8 prints 0, 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

Two objects that point at each otherPop out in the code editor (opens in a new tab)JavaScript
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 friend property that points at Ravi.
  • Line 4 gives Ravi a friend property 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.

Two objects, Asha and Ravi. An arrow labelled asha.friend goes from Asha to Ravi. An arrow labelled ravi.friend goes from Ravi back to Asha. No variable points at either one. Each has a count of 1.no variable points herename: "Asha"Ashaname: "Ravi"Raviasha.friendravi.friendcount: 1count: 1
After lines 5 and 6, no variable points at Asha or Ravi. Each one still has one arrow coming in, from the other, so both counts stay at 1.

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.

A chain frees one object after anotherPop out in the code editor (opens in a new tab)JavaScript
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:

  1. The order's count drops to 0, so the order is freed.
  2. Freeing the order removes its reference to the receipt. The receipt's count drops to 0, so the receipt is freed.
  3. 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:

  1. Pick a group of objects that might only point at each other.
  2. For each object, take its count. Subtract the references that come from inside the group.
  3. 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.

A sum for each object. Asha: 1 link in total, minus 1 link from the pair, equals 0 links from outside. Ravi: the same, 1 minus 1 equals 0. With 0 from outside, both can be freed.all linksfrom the pairfrom outsideAsha1−1=0Ravi1−1=00 from outside: free both
For each object, take all its links, 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.

Step through the cycle collector idea
Step 0 of 5Ready
Your turn: follow the blue line

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.

Running in
  1. script
Next: line 1
  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));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Playground: give Asha references from outside
Subtract the references from inside the pairJavaScript
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));
Model resultcan be freed
from outside0, 0

Asha's and Ravi's counts, after taking away the references from inside the pair.

can be freedasha, ravi

The pair can be freed only when nothing outside points at it.

Try it yourself

Both objects have 0 references from outside. Nothing else uses the pair, so the model can free it.

A short model of the cycle collector idea, made for this lesson. It does not show what your JavaScript engine is doing.

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.

The kind of code that leaked in old Internet ExplorerJavaScript
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 details object.
  • 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 button and details, 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.

Three ways to decide when to free an object
Which wayWhat work it doesWhat happens to a detached loop
Pure reference countingIt 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 collectorIt 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.
TracingIt starts at the roots and follows references.No path from the roots reaches a detached loop, so it is freed.
Left: a roots tag with an arrow to a cart object. The cart is reached, so it is kept. Right: Asha and Ravi point at each other, but no arrow from the roots reaches them, so both are freed.rootscartstart herereached: keptAshaRavinot reached: freed
A tracing collector starts at the roots and follows the arrows. It reaches the cart, so the cart stays. No path reaches Asha and Ravi, so both are freed, even though they point at each other.

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.
A tiny model of when each way does its workPop out in the code editor (opens in a new tab)JavaScript
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 WeakRef is a reference that does not keep its object alive. Its deref() method gives back the object, or undefined once the object has been freed.
  • globalThis.gc() asks V8 to collect right now. It only exists when Node starts with the --expose-gc option.
Check in Node that a detached loop is freedJavaScript
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 WeakRef to Asha, then set both variables to null.
  • Lines 15 to 23 try up to 8 times. Each time, they wait, ask V8 to collect, wait again, and check whether the WeakRef is now empty.
  • Line 24 prints whether the loop was freed.
Checked by a real test

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.
Which way of freeing handles this?
  • 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.
Try it yourself
0 of 6 correct

Sort each card by the way of freeing that handles it.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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.

Ideas that are easy to mix up
When you seeWhat it meansWhat it does not mean
user = nullOne reference was removed.It does not mean the object is freed right then.
A loop of objectsThe objects point at each other.It does not mean every modern JavaScript engine leaks it.
A reference countA number the engine keeps for each object.It is not a number your JavaScript code can read.
The old Internet Explorer leakA loop between page objects and JavaScript that old browsers never freed.It does not mean the same code leaks in browsers today.

Practice exercises

Exercise 1 · Warm-upPredict what the second name prints

What does this program print? Type the output.

Starter codePop out in the code editor (opens in a new tab)JavaScript
let user = { name: "Asha" };
let admin = user;
user = null;
console.log(admin.name);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upPredict the count that is left

    What does this program print? Type the output.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    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"));

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeSay why the loop is never freed

      Asha and Ravi point at each other, and no variable points at them. Why does pure counting keep them?

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeFix the counting rule for loops

        A cycle collector looks at a group of objects. What should it subtract from each count before it decides to free the group?

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeFind out why an app keeps using more memory

          A dashboard page uses more and more memory the longer it stays open. What should you look at first?

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            Reference counting and cycles quiz · 7 questionsScore: first tries count
            1. Question 1 of 7What does pure reference counting do when an object's count reaches 0?

              Choose an answer to see the explanation.

            2. Question 2 of 7What does this print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              let user = { name: "Asha" };
              let admin = user;
              user = null;
              console.log(admin.name);

              Choose an answer to see the explanation.

            3. 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.

            4. Question 4 of 7What problem did old Internet Explorer have?

              Choose an answer to see the explanation.

            5. Question 5 of 7What does QuickJS add to reference counting?

              Choose an answer to see the explanation.

            6. Question 6 of 7Why do most JavaScript engines use tracing?

              Choose an answer to see the explanation.

            7. Question 7 of 7What does this count model print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const 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.

            CompleteFrontend Clear concepts. Working examples.