What you'll learn
Quick Answer
Garbage collection automatically frees memory holding objects your program can no longer reach through any chain of references from live variables. Most engines use mark-and-sweep, often with a generational optimisation; CPython adds reference counting on top. You still get leaks when you keep references you no longer want, such as a growing cache, a closure holding a large array, or an event listener that is never removed.
What counts as garbage
The collector answers one question about every object: can the program still reach it? It starts from a set of roots - global variables, everything currently on the call stack, and variables captured by live closures - and follows every reference outward. Whatever it can reach is live. Everything else is garbage, and its memory can be reclaimed.
This is reachability, not intent. If you meant to discard an object but a reference to it still hangs off some global array, it is reachable, so it stays. Conversely, writing x = null does not free anything on its own; it removes one reference. The object becomes collectable only when the last reference to it is gone.
You never say when collection happens. The engine decides, usually when an allocation would push memory past an internal threshold.
Mark-and-sweep
The classic algorithm runs in two phases. Mark: starting from the roots, walk the entire object graph and set a flag on every object reached. Sweep: scan the heap; anything without the flag is freed, and the flags are cleared for next time.
Because it works from reachability, it handles reference cycles correctly. Two objects that point at each other but that nothing else points to are both unreachable from the roots, so both are collected. A naive reference-count scheme cannot do this.
The cost is a pause. A basic collector stops the program while it walks the heap - "stop the world" - which for a large heap can be tens of milliseconds. Production engines like V8 hide this by marking incrementally, sweeping on a background thread, and doing most of the work concurrently with your code, so pauses stay in the low single-digit milliseconds.
Generational collection
Most objects die young: a function makes a few temporary objects and drops them when it returns. The weak generational hypothesis turns that observation into a speed-up.
V8 splits the heap into a small young generation and a large old generation. New objects are allocated in the young space. A fast, frequent minor GC collects just that space; the few objects that survive a couple of rounds are promoted to the old space. The old space is collected by a slower major GC that runs far less often.
The payoff: churning through millions of short-lived objects is cheap, because each minor GC only touches a small region and only copies the handful of survivors. This is why idiomatic code that allocates freely is not the performance problem it might seem.
Reference counting in Python
CPython takes a different primary approach: every object carries a count of how many references point to it. When the count drops to zero, the memory is freed immediately, with no collector pass.
>>> import sys, gc
>>> a = []
>>> b = a
>>> sys.getrefcount(a) # a, b, and the argument itself
3The count reads one high because passing a into getrefcount is itself a reference. The weakness is cycles: two objects referencing each other never reach zero even when unreachable. So CPython also runs a generational cyclic collector for exactly that case:
>>> gc.get_threshold()
(2000, 10, 10)
>>> gc.collect() # after building an unreachable cycle
2Immediate freeing gives predictable memory use, and objects are usually released the moment they go out of scope. The trade-off is throughput: every assignment, argument pass, and return has to bump a counter up or down, and in CPython protecting those counters is one reason the Global Interpreter Lock exists. Implementations that chase raw speed, such as PyPy, drop reference counting entirely and use a tracing collector instead, which is part of why they run tight loops faster.
A real leak, with numbers
Run this with node --expose-gc. First, honest allocation and cleanup:
baseline heapUsed: 3.39 MB
after allocating 2,000,000 objects: 166.51 MB
after big = null; global.gc(): 3.58 MB (all reclaimed)The collector returned every byte, because once big was null nothing referenced the array. Now the leak:
const em = new EventEmitter();
for (let i = 0; i < 10000; i++) {
const buf = Buffer.alloc(1024);
em.on('tick', () => buf[0]++); // never removed
}
// after global.gc(): 2.77 MB still retained, listenerCount 10000Nothing here is unreachable. The emitter is live, its listener array is live, each arrow function is live, and each closure holds its buf. The browser equivalent is addEventListener on a DOM node that later gets removed without removeEventListener: the listener keeps the node, and everything the handler closes over, alive. A module-level const cache = new Map() that you only ever add to is the same pattern in slow motion.
What you can do about leaks
- Remove listeners on teardown. Pair every
addEventListenerwithremoveEventListener, or pass anAbortControllersignal and callcontroller.abort()once to drop them all. - Bound your caches. Use an LRU with a maximum size, or a
WeakMap/WeakRefso entries disappear when the key object is collected. - Drop large references. Null out big arrays and buffers captured by long-lived closures once you are done with them.
- Measure. Log
process.memoryUsage().heapUsedaround a repeated operation; if retained size climbs every cycle and never falls, you have a leak. Chrome DevTools heap snapshots with the comparison view show which objects are piling up.
Automatic collection removes a whole class of bugs - dangling pointers, double frees - but it does not remove the need to think about what your program is still holding on to.
