Garbage Collection in V8: How Memory Is Actually Reclaimed
A deep dive into V8's garbage collection lifecycle, from young generation scavenging to old generation mark-and-sweep. Understanding how memory is actually reclaimed.
Garbage collection is one of those topics developers “know about” without ever quite seeing. Objects disappear, memory frees up, sometimes it doesn’t, and eventually Node crashes with an OOM error that feels personal.
This article explains garbage collection the way V8 actually experiences it: as a lifecycle, not a grab-bag of algorithms.
What Garbage Collection Exists to Do
Garbage collection exists for exactly one reason:
To reclaim heap memory that your program can no longer reach.
That’s it.
GC does not understand usefulness, intent, or scope. It only understands reachability.
- If an object is reachable, it stays.
- If it is unreachable, it can be reclaimed.
Reachability: The Only Rule
An object is considered alive if it can be reached by following references from a GC root.
Roots are starting points V8 treats as always alive:
- Global variables
- Currently executing stack frames
- Active closures
- Pending promises and callbacks
- Internal runtime references
GC always starts here and walks outward.
A Concrete Mental Picture
function demo() {
let obj = { value: 42 };
}
demo();
What matters is not scope, but reachability:
objis allocated in the heap- The stack frame holds the only reference
demo()returns- The stack frame disappears
- No root can reach the object
- The object becomes garbage
GC may run later, but the verdict is already decided.
Why V8 Divides the Heap
V8 is built on a brutally practical observation:
Most objects die young.
Treating short-lived and long-lived objects the same would be wasteful. So V8 divides the heap by expected lifetime, not by type.
The Heap at a Glance
+---------------------------+
| HEAP |
| |
| +-------------------+ |
| | New Generation | |
| | (Young Space) | |
| +-------------------+ |
| |
| +-------------------+ |
| | Old Generation | |
| | (Old Space) | |
| +-------------------+ |
| |
+---------------------------+
Every object starts at the top. Very few make it to the bottom.
The New Generation: Where Objects Are Born
Almost every allocation begins life in the new generation.
This space is:
- Small
- Fast to allocate
- Collected frequently
The assumption is simple: most of this won’t survive long.
Young Generation Structure (Semi-Spaces)
New Generation
+---------------+ +---------------+
| From-Space | | To-Space |
| (Allocations) | | (Empty) |
+---------------+ +---------------+
The new generation is divided into two equal halves:
From-Space: The active space where objects are currently allocated. This is where your program creates new objects.
To-Space: Initially empty, this space is used only during garbage collection as a destination for objects that survive.
Only one space is active at a time. The other exists purely to help with garbage collection.
Scavenge: Cleaning the Young Generation
The young generation is cleaned using a copying algorithm called Scavenge.
Before GC:
From-Space
+---------------------------+
| objA objB objC X |
| objD X objE |
+---------------------------+
objA,objC,objD,objEare reachableobjB,Xare garbage
During GC, only reachable objects are copied:
To-Space
+---------------------------+
| objA objC objD objE |
+---------------------------+
After GC:
- The old from-space is now completely empty and discarded
- Garbage objects were never touched or copied, they simply disappeared
- The spaces swap roles: The to-space (which now contains all surviving objects) becomes the new from-space where new allocations will happen. The old from-space becomes the new (empty) to-space, ready for the next GC cycle.
This is why short-lived garbage is cheap: dead objects cost nothing.
Promotion: When Objects Refuse to Die
Objects that survive a single cleanup are not trusted yet.
But if an object:
- Survives multiple young-generation GCs, or
- Is too large to comfortably fit
…it gets promoted.
Young Generation Old Generation
+---------------+ +------------------+
| objA objB | ------> | objA objB |
| objC | | |
+---------------+ +------------------+
Promotion is one-way. Once promoted, the object is assumed long-lived.
This transition is where memory mistakes become expensive.
The Old Generation: Where Memory Gets Serious
The old generation holds objects that have proven longevity:
- Global caches
- Long-lived closures
- Configuration objects
- Large in-memory datasets
- Objects retained by listeners
Here, the assumption flips:
Most objects are still alive.
That changes how GC must operate.
Mark-and-Sweep: The Old-Gen Baseline
Old-generation GC begins with mark-and-sweep.
Before GC (Fragmented Heap)
+--------------------------------+
| objA FREE objB FREE objC |
| objD FREE FREE objE |
+--------------------------------+
Mark Phase:
- Start from roots
- Mark reachable objects
Sweep Phase:
- Free unmarked objects
+--------------------------------+
| objA FREE FREE FREE objC |
| FREE FREE FREE objE |
+--------------------------------+
Memory is reclaimed, but fragmentation remains.
Mark-Compact: Fixing Fragmentation
When fragmentation becomes costly, V8 escalates to mark-compact.
After Compaction
+--------------------------------+
| objA objC objE FREE FREE |
| FREE FREE FREE FREE |
+--------------------------------+
Effects:
- Live objects are packed together
- Large contiguous free space is restored
- Allocation becomes faster
- GC pause is longer
Mark-compact is expensive, so it’s used sparingly.
Making GC Less Disruptive
Long stop-the-world pauses would be unacceptable, so V8 uses:
- Incremental marking: work split into slices
- Concurrent marking: work done alongside JS execution
The result is smoother performance at the cost of internal complexity.
Why Memory Problems Appear Late
Here’s the trap:
Young Generation: forgiving
Old Generation: unforgiving
A small accidental reference that survives long enough to get promoted becomes a long-term cost.
Once old-generation pressure rises:
- GC runs more often
- Pauses grow longer
- Allocation slows
- Eventually, allocation fails
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
GC isn’t failing here. It’s obeying your reference graph.
Reachability Graph: Why GC Keeps “Useless” Objects
[GC Root]
|
v
[Closure]
|
v
[Large Object]
|
v
[Array]
As long as any path exists from a root, everything below it stays alive.
This is why memory leaks feel invisible.
The Object Lifecycle (End-to-End)
Allocation
|
v
Young Generation
|
+-- Dies quickly --> Reclaimed cheaply
|
+-- Survives
|
v
Promotion
|
v
Old Generation
|
+-- Eventually unreachable --> Expensive cleanup
|
+-- Accidentally retained --> Memory pressure
This diagram explains nearly every Node.js OOM crash.
The Rule That Explains Every Leak
If something is reachable, it will stay alive, no matter how useless it is.
Garbage collection cannot save you from references you forgot you were holding.
Garbage collection is not about deleting objects. It’s about proving you no longer need them.
Once you can see the heap evolve (birth, survival, promotion, fragmentation), memory behavior stops being mysterious and starts being mechanical.
Next: In the next blog, we’ll cover debugging memory issues, using the mental model from this blog to make sense of heap snapshots, retainer paths, and GC logs.