cf.completefrontendCode editorOpen lab
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 aliasesExplain why an object stays alive while at least one reference still points to it.
  • 02
    Spot the cycle problemShow why two detached objects can keep each other's pure reference count above zero.
  • 03
    Compare 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.

Definition

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

One object, then an aliasPop out in the code editor (opens in a new tab)JavaScript
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.

A local object stops being named when a function returnsPop 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 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.

Real-life analogyBalloons on strings

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

A tiny counting teaching modelPop 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"));

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.

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

Replay an instrumented teaching model. It records real calls to this lesson's counter, not a JavaScript engine debugger.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
 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"));
CallStoreChangeResultRun = next line. 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 can temporarily hold another 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 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

A detached two-object cyclePop 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 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.

Releasing a chain can cascadePop out in the code editor (opens in a new tab)JavaScript
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.

Step through a cycle-collection idea
Step 0 of 5Ready
Your turn: follow the blue line

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.

Running in
  1. script
Next: line 1
Click the blue line to take the next stepPop out in the code editor (opens in a new tab)JavaScript
  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));
CallStoreChangeResultRun = next line. 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 an outside reference
Subtract links inside the groupJavaScript
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));
Teaching-model resultcollectible
outside counts0, 0

References inside the pair have been subtracted.

collectible namesasha, ravi

The group is collectible only when it has no outside owner.

Try it yourself

Both outside counts are zero, so this teaching model can collect the detached group.

This is a short teaching model of the cycle-collection idea. It is not a view into your JavaScript engine.

A historical Internet Explorer leak

Historical old IE shapeJavaScript
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

Counting and tracing compared
ApproachMain workCycle result
Pure reference countingUpdate a count on every new or removed referenceFrees immediately at zero, but a detached cycle stays above zero.
Reference counting plus cycle collectionCount normally, then inspect groups that may only point inside themselvesKeeps predictable freeing and repairs the cycle gap.
Tracing collectionStart at roots and follow referencesDetached 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.

A tiny model of when the work happensPop 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 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

Node-only detached-cycle probeJavaScript
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");
Verified runtime fact

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.

Which collector idea fits?
  • 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.
Try it yourself
0 of 6 correct

Sort each card by the idea that explains it.

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

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.
Easy ideas to confuse
ThingIt meansIt does not mean
user = nullOne reference was removedThe object is definitely freed right then.
A cycleObjects point at each otherEvery modern JavaScript engine leaks it.
Reference countAn engine bookkeeping numberA number JavaScript code can read.
Historical IE leakAn old cross-collector cycle problemA leak in current browsers from this code alone.

Practice exercises

Exercise 1 · Warm-upPredict the alias output

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 remaining count

    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 · PracticeName the cycle problem

      Why does pure counting keep a detached Asha/Ravi pair?

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

        Exercise 4 · PracticeRepair the model

        What should a cycle collector subtract before deciding whether to free the group?

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

          Exercise 5 · ChallengeApply it to a real app

          A dashboard keeps growing. What should you inspect 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 a pure reference counter do when a count reaches zero?

              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 do two detached objects defeat pure reference counting?

              Choose an answer to see the explanation.

            4. Question 4 of 7What historical 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 trace instead?

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

            CompleteFrontend Clear concepts. Working examples.