How Garbage Collection Works in JavaScript
JavaScript frees memory automatically by tracking reachability, not reference counts. How the mark-and-sweep algorithm works and what causes leaks anyway.
Garbage collection in JavaScript is the automatic process of freeing memory occupied by objects that are no longer reachable from anywhere your code can still access. You never call free() or delete on a value the way you would in a manually managed language — the engine tracks what’s still in use and reclaims everything else on its own. Understanding roughly how it works explains both why JavaScript rarely needs manual memory management and why memory leaks can still happen despite that.
Reachability, not reference counting
Early garbage collectors, and some other languages today, use reference counting: each object tracks how many references point to it, and gets freed the moment that count hits zero. It’s simple, but it has a fatal flaw — two objects that reference each other, but that nothing else references, will keep each other’s count above zero forever, leaking memory even though the pair is unreachable from anywhere useful.
JavaScript engines instead use reachability: an object is considered live if it’s reachable through some chain of references starting from a set of roots — global variables, currently executing function scopes, and a few other engine-tracked entry points. Anything not reachable from a root, including a cyclic pair of objects referencing only each other, is garbage, full stop.
Mark-and-sweep, in outline
The dominant algorithm implementing reachability is mark-and-sweep, and it runs in two phases:
- Mark. Starting from the roots, the collector walks every reference it can reach, marking each object it visits as “live.” This is a graph traversal — objects reference other objects, and the collector follows every edge.
- Sweep. Anything not marked after the traversal completes is garbage. The collector reclaims that memory, making it available for future allocations.
This directly solves the reference-cycle problem: two objects referencing only each other are simply never visited during the mark phase, because nothing traces a path to them from a root. Both get swept regardless of how many references point between them.
Generational collection
Real engines, including V8 (which powers both Chrome and Node.js), add an optimization on top of mark-and-sweep called generational collection, based on an empirical observation: most objects die young. A huge share of allocations are short-lived — temporary values inside a function call, intermediate objects in a computation — while a much smaller set survive for the life of the program.
V8 splits the heap into a small young generation and a larger old generation. New objects are allocated in the young generation, which is collected frequently using a fast, cheap algorithm, since most of what’s there is already garbage by the time collection runs. Objects that survive a few collection cycles get promoted to the old generation, which is collected far less often using a more thorough (and more expensive) full mark-and-sweep pass. This means the expensive full-heap scan only has to run occasionally, on the smaller pool of objects that have actually proven they’ll stick around.
This split is why allocation-heavy code — tight loops that create lots of short-lived temporary objects — is usually not the performance problem developers expect it to be. The young-generation collector is specifically optimized for exactly that pattern, and most of those objects are reclaimed within a single fast pass rather than surviving long enough to burden the far more expensive old-generation collector at all.
Why leaks still happen
If the engine reclaims everything unreachable, how does JavaScript still leak memory? Because “unreachable” and “unused” aren’t the same thing — a leak happens when your code keeps a reference to something you’re done with, making it reachable (and therefore un-collectible) indefinitely, even though nothing actually needs it anymore. Common causes:
- Forgotten event listeners. A listener attached to a long-lived object (like
window) holds a reference to its callback, which in turn can hold references to everything in its closure. If the listener is never removed, none of that memory is ever freed. - Stale closures. A closure captures its enclosing scope by reference. If a long-lived function retains a closure that captured a large object it no longer needs, that object stays reachable through the closure alone.
- Growing caches without eviction. A
Mapor object used as a cache, with entries added but never removed, grows without bound — every entry is reachable through the cache itself, so nothing about it looks like a leak to the collector. - Detached DOM nodes. Removing an element from the document doesn’t free it if JavaScript still holds a reference to it — the node stays reachable, along with its entire subtree, until every reference to it is gone.
Garbage collection’s tradeoff: pauses
Mark-and-sweep has to walk live objects, and a naive implementation would need to pause all JavaScript execution while it does — a stop-the-world pause. For a large heap, that pause is noticeable, showing up as dropped frames or input lag. Modern engines mitigate this with incremental and concurrent collection: breaking the mark phase into small chunks interleaved with normal execution, or running parts of collection on a background thread entirely. This is one of the reasons a page can feel smooth for a long stretch and then briefly stutter — a collection cycle catching up on accumulated garbage.
The takeaway
JavaScript’s garbage collector frees memory based on reachability from a set of roots, not reference counts, which is what lets it correctly collect reference cycles that would leak in a naively reference-counted system. V8 layers a generational optimization on top of the core mark-and-sweep algorithm, collecting short-lived objects cheaply and full-heap scans rarely. Leaks still happen — not because the collector fails, but because your own code keeps something reachable, through a forgotten listener, a stale closure, or an unbounded cache, long after it’s actually needed.
Keep reading
Takina · · 6 min read How to Break Up Long Tasks in JavaScript
Long tasks block the main thread for 50ms or more and make pages feel frozen. How to split them with yielding, scheduler.yield(), postTask, and workers.
Takina · · 5 min read Microtasks vs Macrotasks in JavaScript, Explained
Microtasks (promise callbacks) run before the next macrotask (timers, events). How the two queues are ordered, and why it matters for your code.
Takina · · 5 min read OffscreenCanvas API Explained
OffscreenCanvas lets you render canvas graphics off the main thread, in a web worker, so heavy drawing work stops blocking scrolling, input, and animation.