You'd think the garbage collector walks around memory looking for junk to throw away.

It doesn't. It finds everything you can still reach, then deletes whatever's left.

An arrow is deleted, green marks spread from the roots, and the unreached objects are swept away

That's the whole idea. Here's what just happened.

Your objects are a graph

Object graph with two roots and six objects, including Avatar and Thumb pointing at each other

Objects point at other objects. req points at a Request, the Request points at a User, and so on.

The blue boxes are roots. That's what your code can touch right now without being handed anything, like local variables and globals.

Now look at Avatar and Thumb on the right. They point at each other. Keep an eye on those two.

Delete one arrow

request.user = null; 

The User object is still sitting in memory. But nothing leads to it anymore, and nothing leads to Avatar or Thumb either, because the only way in was through User.

Mark

When the GC runs, it starts at the roots and follows every arrow it can. Everything it reaches gets marked.

Request, Config and Theme marked green. User, Avatar and Thumb outlined in red

Request, Config and Theme got marked. User, Avatar and Thumb didn't, because no arrow led there.

The GC never decided those three were garbage. It just never got to them.

Sweep

Then it goes through memory and frees everything without a mark.

User, Avatar and Thumb are gone. Only the reachable objects remain

The cycle is gone too. Avatar and Thumb were still pointing at each other, but that doesn't help them. No path from a root means they go.

You can check this yourself. Save this as cycle.js:

let avatar = { name: "avatar" }; let thumb = { name: "thumb", avatar }; avatar.thumb = thumb; const check = new WeakRef(avatar); avatar = thumb = null; setTimeout(() => { gc(); console.log(check.deref()); // undefined }, 0); 

Run it with node --expose-gc cycle.js. A WeakRef lets you look at an object without keeping it alive, so undefined means the pair was collected, even though they still point at each other.

Not every language does this

The other way is reference counting. Each object keeps a count of the arrows pointing at it. When the count hits 0, it's freed right away.

Same graph, same delete:

Reference counting: User is freed at once, Avatar and Thumb stay yellow with a count of 1

User hit 0 and went instantly, which is nice. No waiting for a GC run.

But look at Avatar and Thumb. Both are stuck at 1, because each one is pointing at the other. Your code can't reach them and their counts will never hit 0. That's a leak.

Swift counts references, which is why you mark one side of a cycle as weak there. Python counts too, but it has a backup collector to catch cycles. JavaScript, Go, Java and C# trace.

So can you still leak memory in JavaScript?

Yes. The GC doesn't know what you're done with. It only knows what's reachable.

const cache = new Map(); function handle(req) { cache.set(req.id, req.body); } 

cache sits at the top of the module, so everything in it stays reachable. You might never read those entries again, but the GC can't know that. They stay forever.

Same thing with an event listener you never remove.

Reachable, not used. That's the rule.

You can play with the graph here. Click any arrow to delete it, then flip to reference counting and see what gets stuck: https://software-atlas.haiduc0147.dev/t/gc#reachability