A Node.js process can allocate hundreds of thousands of temporary objects, run several garbage collections, and still report a growing heap. Is that a leak, a collection pause problem, or normal allocation behavior? A GC trace alone cannot answer all three.

A garbage collector determines which objects are reachable, not which objects the application still intends to use. Unwanted data can survive because a cache or closure still references it. Conversely, objects in a reference cycle can be reclaimed if the entire cycle is unreachable.

This article explains how V8, the JavaScript engine used by Chrome and Node.js, makes those decisions, how young and old generation collectors differ, and how to interpret an actual Node.js GC trace without treating a memory reading as proof of a leak. Other engines share the reachability principle but need not use V8's heap layout or algorithms.

Reachability is about references, not usefulness

Consider a cache:

const history = new Map();

let screen = {
  id: 'settings',
  data: new Array(10_000).fill('x'),
};

history.set(screen.id, screen);
screen = null; // The cache still references the object.

// Later, when the cached screen is no longer needed:
history.delete('settings');

Setting screen to null removes one reference. It does not erase the object or release its memory. While the Map is reachable, its entry keeps the object reachable too. Removing the entry eliminates that retaining path, but the object is only eligible for collection if no other strong references remain. Eligibility is not a promise of immediate collection.

V8's collector starts from roots, such as active execution state and globally reachable objects, and follows references through an object graph. In the cache example, the important path is:

root -> history Map -> "settings" entry -> screen object -> data array

Now imagine two objects that refer only to each other:

root       A <-> B
 |          (no path from root)
 +-> cache

A and B form a cycle, but the cycle is unreachable. A tracing collector can reclaim it. This is why the older reference-counting explanation, "anything with a nonzero reference count stays alive," does not describe modern JavaScript garbage collection. The governing test is whether a path exists from a root.

The MDN memory-management guide explains the language-level model, while V8's description of marking explains one engine's implementation. The language does not guarantee when an unreachable object will be reclaimed.

Marking, sweeping, and compacting do different jobs

Mark-and-sweep is the useful starting model, but it is not one indivisible operation.

WorkWhat the collector doesWhat it does not imply
MarkTraverse from roots and identify reachable objectsThe collector has discovered what your app meant to retain
SweepRecycle space occupied by unreachable objects, often through free listsThat memory is immediately returned to the operating system
CompactMove selected surviving objects together and update referencesEvery surviving object is moved on every collection

A collector can reuse reclaimed slots for later allocations. Process memory can remain high even after dead objects have been reclaimed, because the runtime may retain committed pages for reuse. That makes process RAM, JavaScript heap usage, and the size of reachable application data related but different measurements.

Compaction solves a different problem from sweeping. Imagine free slots scattered between living objects. Total free memory might be sufficient, yet no single gap is large enough for a desired allocation. Moving survivors can create more useful contiguous space, but moving objects and repairing references also costs work. V8 therefore selects pages for compaction based on fragmentation rather than compacting everything indiscriminately.

These details are described in V8's Orinoco collector overview and its current heap documentation.

Why V8 separates young and old objects

Many allocated objects stop being needed quickly. V8 uses this generational hypothesis to avoid scanning the entire heap each time the application fills a small allocation area.

In the usual young-generation path, new objects are allocated in a nursery. A minor GC examines the young generation, moves surviving objects into available space, and may eventually promote survivors to the old generation. This is the Scavenger approach: copying the survivors can be less work than handling every dead allocation individually, especially when few survive. V8 also documents an alternative minor mark-sweep collector, which is not its default in the referenced implementation.

A major GC covers the old generation and, in V8's full-heap collection model, the rest of the heap. Marking identifies the live graph; sweeping reclaims dead space; selected pages may be compacted. Major collection is more expensive in some workloads, but its work is not necessarily a single long pause.

The point of generations is not that old objects become permanently immune to collection. It is that objects with different observed lifetimes can be managed with different strategies. Surviving several collections makes an object a candidate for promotion; exact thresholds and heap sizes are implementation details, not JavaScript contracts.

Source: V8's current garbage-collection documentation. For the evolution of V8's copying young-generation collector, see its parallel Scavenger explanation.

The old-to-young reference problem

A subtle question follows: if minor GC mostly scans the young generation, how does it notice that an old object points to a new one?

Consider this ordinary code:

const session = { active: null };

function receiveMessage(message) {
  session.active = { message };
}

Imagine, for explanation, that session survived long enough to be in old space and that the newly created { message } is in young space. The old object now points to the new one. A minor collector that ignored this edge could mistakenly reclaim an object that is still reachable.

V8 uses a write barrier with remembered information about relevant old-to-young pointers. That lets minor GC account for those references without traversing all old objects every time. The runtime handles this tracking when the program writes properties; JavaScript application code does not call a write-barrier API.

The example demonstrates the reference relationship, not a guaranteed promotion event. You cannot inspect an ordinary JavaScript object and assume which generation V8 has placed it in. The same V8 heap documentation describes remembered sets and marking barriers.

What happens while JavaScript keeps running?

A simple textbook collector stops JavaScript, traces everything, reclaims memory, and resumes execution. That model explains why collection can cause pauses, but it misses much of V8's effort to shorten them.

Three terms describe different ways of scheduling GC work:

  • Parallel: multiple threads help perform collection work during a pause. The pause can be shorter, but application JavaScript is not necessarily running at the same time.
  • Incremental: collection work is divided into smaller portions, with JavaScript allowed to run between portions. This reduces the length of individual pauses without guaranteeing less total work.
  • Concurrent: helper threads do some collector work while the main thread continues executing JavaScript. The object graph can change during marking, so the collector needs mechanisms such as write barriers to preserve correctness.

V8 combines these approaches. There can still be stop-the-world phases, including final marking or certain moving and pointer-update work. Concurrent does not mean pause-free, and a shorter main-thread pause is not the same as zero CPU cost. An application can still suffer from high allocation pressure, excessive object survival, or expensive major collections.

V8's Orinoco overview distinguishes these techniques. Its concurrent-marking article explains why the changing graph makes concurrent collection harder.

A reproducible trace: temporary versus retained allocations

The quickest way to see the distinction between allocation and retention is to create two workloads that allocate similar objects but hold different numbers of them.

Save the following as gc-trace.cjs. This is a Node.js/V8 diagnostic example, not browser code or a performance benchmark.

const { getHeapSpaceStatistics } = require('node:v8');

const mode = process.argv[2];
if (!['transient', 'retained'].includes(mode)) {
  throw new Error('Run with transient or retained');
}

const held = [];
for (let round = 0; round < 100; round++) {
  const batch = Array.from({ length: 4000 }, (_, id) => ({
    id,
    label: `item-${round}-${id}`,
  }));
  if (mode === 'retained') held.push(batch);
}

const mib = (bytes) => (bytes / 1024 / 1024).toFixed(1);
console.log({
  mode,
  batchesKept: held.length,
  heapUsedMiB: mib(process.memoryUsage().heapUsed),
});
console.log(
  getHeapSpaceStatistics()
    .filter((space) => ['new_space', 'old_space'].includes(space.space_name))
    .map((space) => ({
      name: space.space_name,
      usedMiB: mib(space.space_used_size),
    }))
);

Run separate processes so one workload does not contaminate the other's heap:

node --trace-gc gc-trace.cjs transient
node --trace-gc gc-trace.cjs retained

Both runs allocate 100 batches of 4,000 objects. In transient mode the batch binding is replaced on each iteration and the old batch is not deliberately retained. In retained mode held stores every batch and remains in scope until the summary is printed. The second workload therefore has more live data to manage.

The GC trace may include Scavenge entries for young-generation collection and Mark-Compact entries for major collection. A trace line commonly shows heap usage before and after collection, a duration, and a reason for the event. The precise event labels, timings, and space sizes depend on the Node/V8 build and runtime conditions.

In an isolated run of this exact script on Node.js 22.16.0 (V8 12.4), both modes produced Scavenge events. The retained mode also showed Mark-Compact and higher old-space usage at the end. These are observations from a small diagnostic fixture, not representative latency estimates for a browser or proof that any real application leaks.

Read the comparison carefully:

  1. Allocation count alone does not tell you how much stays alive. The two runs create the same number of batch objects.
  2. A GC entry is not automatically a problem. Repeated collection is part of normal operation.
  3. A higher final heap reading is not automatically a leak. The retained workload intentionally keeps the arrays. An application cache can be legitimate.
  4. The absence of a major GC in a short run proves little. A longer run or different heap pressure can change the schedule.
  5. Do not compare two one-off duration numbers as a benchmark. Warmup, scheduling, V8 version, machine load, and allocation layout matter.

The Node.js tracing guide explains --trace-gc output. The Node.js V8 API also warns that heap-space names and ordering are not stable guarantees. The script filters named spaces rather than assuming array positions.

Turning a GC trace into a debugging decision

A trace can tell you when V8 collected and roughly how much heap space it reclaimed. It cannot, by itself, identify the application reference responsible for retaining a particular object.

ObservationNext questionBetter tool
Many allocations and frequent GC during a user interactionWhich functions allocate, and do GC pauses overlap visible delays?Chrome DevTools Performance recording with memory metrics and allocation profiling
Live data grows after an action is repeatedly undoneWhich strong reference prevents reclamation?Heap snapshot comparison and retaining paths
Old-space usage rises, then stabilizesIs this bounded cache/state, initialization, or unnecessary retention?Repeated same-workload snapshots plus application lifecycle knowledge
Process RAM stays high while live JS heap is stableIs the runtime reusing committed memory, or is memory outside the JS heap growing?Process and browser memory inspection alongside heap data

Chrome's memory troubleshooting guide separates memory leaks, unnecessary memory use, and frequent collections. If the symptom is a retained DOM node or a closure keeping an obsolete panel alive, the dedicated JavaScript memory-leak debugging walkthrough follows a retaining path in DevTools. This article deliberately concentrates on the collector and how to interpret its work.

Weak references do not replace lifecycle design

A Map normally keeps its keys and values strongly reachable while the map itself is reachable. A WeakMap treats object keys differently: an entry cannot, by itself, keep an otherwise unreachable key alive. Its semantics involve ephemerons, which handle cases where the stored value refers back to the key. Replacing every cache with WeakMap is not a general fix, since weak collections cannot be enumerated and do not provide predictable eviction timing.

Likewise, WeakRef and FinalizationRegistry are unsuitable for required cleanup. Finalizers may run later than expected or not at all. Closing a socket, unsubscribing from an event, or releasing another external resource should have an explicit lifecycle rather than depending on a GC callback. See MDN's weak-reference and finalization guidance.

GC also has a different responsibility from the engine's parsing and optimization pipeline. If you're investigating code execution and deoptimization instead of object lifetime, see how the JavaScript engine executes and optimizes code.

The useful mental model is this: reachability determines what can survive, generations determine what the collector usually examines, and its scheduling strategy determines how that work competes with your application. When performance degrades, distinguish those three questions before changing code.