Node.jsV8MemoryJavaScriptGarbage Collection

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:

  1. obj is allocated in the heap
  2. The stack frame holds the only reference
  3. demo() returns
  4. The stack frame disappears
  5. No root can reach the object
  6. 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, objE are reachable
  • objB, X are 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:

  1. GC runs more often
  2. Pauses grow longer
  3. Allocation slows
  4. 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.