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.
That's the whole idea. Here's what just happened.
Your objects are a graph
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 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.
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:
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





