Chapter 35

Memory Management: Generational GC, WeakRef, and DevTools Memory Profiling

Chapter 35: Memory Management — Generational Garbage Collection, WeakRef, and DevTools Heap Analysis

A Node.js service with a memory leak crashed 72 hours after going live — not because the programmer wrote wrong logic, but because they put a reference to a DOM element into a global Map, and that DOM element was never removed. The garbage collector kept concluding that someone still needed it.

Core Questions of This Chapter: How does the JavaScript engine track which objects are "alive" and which can be reclaimed? When the garbage collector makes mistakes, how does memory leak away, bit by bit, past the point of no return?

After Reading This Chapter You Will Understand:


Level 1 · What You Need to Know (1-3 Years of Experience)

JavaScript's Memory Is Not Automatically "Free"

Although JavaScript has garbage collection, memory leaks still happen frequently. The garbage collector can only reclaim objects it believes have "no more references" — if you accidentally hold a reference to an object, the GC will never reclaim it.

The most dangerous reference-retention patterns:

// Dangerous pattern 1: global variable accidentally holding large amounts of data
let cache = {};  // at module top level
function process(data) {
  cache[data.id] = data;  // stuffing things in on every call, never cleaning up
  return transform(data);
}

// Dangerous pattern 2: event listener not removed
function setupComponent(element) {
  const handler = (event) => {
    console.log(element, event);  // handler closure holds a reference to element
  };
  document.addEventListener('click', handler);
  // Without removeEventListener, neither handler nor element can be GC'd
}

// Dangerous pattern 3: timer holding a reference
function startPolling(heavyData) {
  setInterval(() => {
    process(heavyData);  // closure holds heavyData
  }, 1000);
  // Without clearInterval, heavyData will never be GC'd
}

WeakMap and WeakSet Are the Right Tools for "Attached Data"

// Wrong: using a regular Map to associate DOM elements with data
const elementData = new Map();

function attachData(element, data) {
  elementData.set(element, data);  // element won't be GC'd — Map holds a strong reference
}

// When the DOM element is removed from the page, its entry in the Map lingers forever

// Right: use WeakMap
const elementData = new WeakMap();

function attachData(element, data) {
  elementData.set(element, data);  // if element is no longer referenced elsewhere, GC can collect it
  // WeakMap holds a weak reference to the key, doesn't prevent GC
}

Quick-Reference Checklist for 6 Memory Leak Patterns

Pattern Symptom Quick Fix
Accumulating global variables Memory grows linearly with request count Set LRU cache size limit
Event listeners not removed Memory doesn't drop after SPA route changes Call removeEventListener on component destroy
Closures holding large objects Memory doesn't release after function returns Check if closure captures unnecessary variables
DOM references retained in JS Memory doesn't drop after removing DOM Replace Map with WeakMap/WeakRef
Timers not cleared Background tasks run indefinitely Call clearInterval/clearTimeout in cleanup
Infinitely growing queues Producer faster than consumer Set queue length limit

Basic DevTools Workflow for Detecting Memory Leaks

1. Open Chrome DevTools → Memory tab
2. Select "Heap snapshot"
3. Perform one complete operation (e.g., open a page, then close it)
4. Take another snapshot
5. In the second snapshot, select "Comparison" view (compares the two snapshots)
6. Sort by "# Delta" (count increment)
7. Objects whose count only ever increases are potential leak points

Level 2 · How It Works (3-5 Years of Experience)

V8's Generational Assumption and Heap Structure

A widely validated empirical rule in garbage collection theory: most objects either die quickly or live long. Short-lived objects (like temporary values created during function calls) are typically no longer needed within milliseconds of creation. Long-lived objects (like application state and caches) tend to persist for a long time.

V8 designed the generational heap based on this observation:

V8 Heap Structure
┌────────────────────────────────────────────────────────────┐
│                        V8 Heap                              │
│                                                             │
│  ┌─────────────────────────┐  ┌────────────────────────┐  │
│  │      New Space           │  │   Old Space             │  │
│  │   ~1-8 MB (configurable) │  │   Hundreds MB to GB    │  │
│  │                         │  │                        │  │
│  │  ┌──────────┐           │  │  Contains:             │  │
│  │  │ From     │           │  │  • Long-lived objects  │  │
│  │  │ Space    │           │  │  • Large Object Space  │  │
│  │  └──────────┘           │  │    (>512KB)            │  │
│  │  ┌──────────┐           │  │  • Code Space (JIT)    │  │
│  │  │ To       │           │  │  • Map Space (HC objs) │  │
│  │  │ Space    │           │  │                        │  │
│  │  └──────────┘           │  │                        │  │
│  └─────────────────────────┘  └────────────────────────┘  │
└────────────────────────────────────────────────────────────┘

New Space: The Scavenge Algorithm

New Space uses the Scavenge (also called Semi-space Copying GC) algorithm:

Initial state:
From Space [A, B, C, D, E]   To Space [empty]

Run GC:
1. Starting from root objects (stack variables, global variables),
   mark all reachable objects:
   Reachable: A, C, E
   Unreachable: B, D

2. Copy surviving objects to To Space:
   To Space [A', C', E']

3. Clear From Space, then swap the roles of the two spaces:
   From Space ← original To Space [A', C', E']
   To Space ← original From Space [empty, ready for next use]

Result: B and D have been reclaimed, memory is compacted (no fragmentation)

Scavenge characteristics:

Survival count and promotion: Every object in New Space has a "survival count" counter. If an object survives two Scavenge cycles (count >= 2), it gets promoted to Old Space.

// What objects get promoted to Old Space?
// 1. Objects that survive ≥ 2 GC cycles in New Space
// 2. Objects whose size exceeds the remaining To Space capacity
// 3. Objects directly allocated larger than 512KB

Old Space: Mark-Sweep-Compact Algorithm

Old Space uses the traditional mark-sweep-compact algorithm:

Phase 1: Mark
Starting from root objects, depth-first traverse all reference chains.
Mark all reachable objects (set a bit in the object header).

Roots → A → B → C
              ↓
              D
A, B, C, D are all marked.
Unmarked objects are treated as garbage.

Phase 2: Sweep
Scan the entire Old Space heap, free unmarked objects.
Freed memory blocks are added to the free list.

Before: [A][garbage][B][C][garbage][garbage][D]
After:  [A][free   ][B][C][free   ][free   ][D]
Free list: {addr: 0x100, size: 16}, {addr: 0x130, size: 32}

Phase 3: Compact [optional, triggered on demand]
Move surviving objects to one end of the heap, eliminating memory fragmentation.
After: [A][B][C][D][      free      ]

The problem with Mark-Sweep: it causes Stop-The-World pauses — during the marking phase, the JavaScript execution thread must stop and wait for the GC to complete its scan. In large applications, this pause can reach 100ms or more, causing noticeable page freezes.

Incremental Marking: From 100ms to 5ms

V8 solves the pause problem with incremental marking:

Traditional full marking:
JS executes ────────────────── PAUSE (100ms GC) ────────────────── JS executes

Incremental marking:
JS executes ── Pause(5ms) ── JS executes ── Pause(5ms) ── JS executes ── Pause(5ms)

Total GC time is similar, but spread across many small pauses the user doesn't notice

The challenge of incremental marking: Write Barriers

During incremental marking, JavaScript continues executing, potentially creating new objects or modifying existing references. This creates a consistency problem: if GC has already scanned object A, then JavaScript assigns a reference to unscanned object B to A, GC would incorrectly conclude that B is unreachable.

V8 solves this with write barriers: every time an object reference is modified, write barrier code runs automatically, adding the modified object to a "re-scan" list.

// Write barrier in pseudocode
function writeBarrier(object, field, newValue) {
  object[field] = newValue;
  // Write barrier:
  if (GC.isRunning && GC.alreadyScanned(object)) {
    GC.greyList.add(object);  // add object to the re-scan queue
  }
}

Write barriers have a performance cost: each property write incurs an additional check. V8 selects different write barrier strategies for different scenarios (Minor GC and Major GC use different barriers) to minimize overhead.

Concurrent Marking: Further Eliminating Pauses

V8 introduced concurrent marking in 2018 (Chrome 64):

Main thread (JS execution):
JS ──────────────────────────────────────── JS

Background GC thread:
         ← concurrent marking (doesn't block main thread) →
                                         Final pause (1-2ms)

Concurrent marking runs on a separate GC thread — the main thread can continue executing JavaScript. Only the final confirmation of "which objects survived" requires an extremely brief pause (typically 1-2ms).

Actual performance data:

Deep Analysis of 6 Classic Memory Leak Scenarios

Scenario 1: Closures capturing large intermediate variables

function processLargeData() {
  const hugeArray = new Array(1000000).fill(0);  // ~8MB

  // The inner function only uses result, but the closure captures
  // the entire lexical environment of processLargeData —
  // meaning hugeArray is also "accidentally" held by the closure
  const result = hugeArray.reduce((sum, x) => sum + x, 0);

  return function getResult() {  // this function is returned and held long-term
    return result;
  };
}

const getResult = processLargeData();
// hugeArray (8MB) cannot be GC'd, even though getResult only needs result (a number)

// Fix: dereference the large intermediate variable before returning the closure
function processLargeData() {
  let hugeArray = new Array(1000000).fill(0);
  const result = hugeArray.reduce((sum, x) => sum + x, 0);
  hugeArray = null;  // ← explicitly sever the reference, allowing GC to collect it

  return function getResult() {
    return result;
  };
}

Scenario 2: DOM references lingering in a JavaScript Map

// Common "augment DOM element" pattern
const elementCache = new Map();

function enhanceButton(btn) {
  elementCache.set(btn, {
    clickCount: 0,
    handler: () => elementCache.get(btn).clickCount++
  });
  btn.addEventListener('click', elementCache.get(btn).handler);
}

// When the button is removed from the DOM:
document.body.removeChild(btn);
// btn is gone from the DOM tree, but the Map still has a strong reference
// Result: the btn node (and all its subtree) cannot be GC'd

// Fix: use WeakMap (weak reference to the key)
const elementCache = new WeakMap();
// When btn is no longer referenced anywhere else, WeakMap doesn't prevent GC
// The corresponding entry is automatically removed

Scenario 3: The anonymous function problem with event listeners

class VideoPlayer {
  constructor(element) {
    this.element = element;
    this.data = new Array(100000);  // large data

    // Creates a new anonymous function on every call —
    // impossible to remove via removeEventListener (different reference each time)
    this.element.addEventListener('play', () => {
      this.onPlay();  // arrow function captures this (the entire VideoPlayer instance)
    });
  }

  onPlay() {
    console.log('playing');
  }

  destroy() {
    // This line does nothing! The anonymous function passed in
    // is a different reference from the one registered
    this.element.removeEventListener('play', () => { this.onPlay(); });
  }
}

// Fix: save the listener reference
class VideoPlayer {
  constructor(element) {
    this.element = element;
    this.data = new Array(100000);

    // Bind and save the reference
    this._onPlay = () => this.onPlay();
    this.element.addEventListener('play', this._onPlay);
  }

  onPlay() {
    console.log('playing');
  }

  destroy() {
    this.element.removeEventListener('play', this._onPlay);  // correct removal
    this._onPlay = null;
    this.data = null;  // proactively release large data
  }
}

Scenario 4: Object reference accumulation in Map/Set

// Common pattern in real-time data processing
const processedRequests = new Map();

function handleRequest(request) {
  processedRequests.set(request.id, {
    data: request.body,  // potentially large
    timestamp: Date.now(),
  });
  // ...processing logic...
}

// Problem: processedRequests grows without bound as requests come in
// There's no cleanup mechanism

// Fix A: LRU cache with size limit
const MAX_CACHE_SIZE = 1000;
const cache = new Map();

function setCached(key, value) {
  if (cache.size >= MAX_CACHE_SIZE) {
    // Delete the oldest entry (Map iterates in insertion order)
    const firstKey = cache.keys().next().value;
    cache.delete(firstKey);
  }
  cache.set(key, value);
}

// Fix B: TTL (time-to-live)
const timedCache = new Map();

function setWithTTL(key, value, ttlMs) {
  timedCache.set(key, {
    value,
    expiresAt: Date.now() + ttlMs,
  });
}

function getFromCache(key) {
  const entry = timedCache.get(key);
  if (!entry) return undefined;
  if (Date.now() > entry.expiresAt) {
    timedCache.delete(key);  // lazy expiration
    return undefined;
  }
  return entry.value;
}

Scenario 5: Circular references from timer closures

// A problem that appears frequently in single-page applications
function createWidget(container) {
  const heavyData = loadHeavyData();

  const timerId = setInterval(() => {
    updateUI(container, heavyData);  // closure holds both container and heavyData
  }, 1000);

  // Return a controller
  return {
    stop() {
      clearInterval(timerId);  // correctly stops the timer
    }
  };
}

// If stop() is never called (forgotten during route navigation),
// both container (DOM node) and heavyData (large data) will never be GC'd

// Safer pattern: use WeakRef
function createWidget(container) {
  const heavyData = loadHeavyData();
  const containerRef = new WeakRef(container);  // weak reference, doesn't prevent container GC

  const timerId = setInterval(() => {
    const el = containerRef.deref();
    if (!el || !document.contains(el)) {
      clearInterval(timerId);  // container is gone, stop automatically
      return;
    }
    updateUI(el, heavyData);
  }, 1000);
}

Scenario 6: Reference accumulation in Promise chains

// Unhandled Promise rejections in high-concurrency servers cause memory leaks
const pendingOperations = [];

async function doOperation(data) {
  const promise = fetch('/api', { body: JSON.stringify(data) });
  pendingOperations.push(promise);  // saved for cancellation
  return await promise;
}

// Problem: pendingOperations grows continuously — completed Promises aren't removed

// Fix: auto-remove on completion
async function doOperation(data) {
  const promise = fetch('/api', { body: JSON.stringify(data) });
  pendingOperations.push(promise);

  try {
    return await promise;
  } finally {
    const idx = pendingOperations.indexOf(promise);
    if (idx !== -1) pendingOperations.splice(idx, 1);
  }
}

Level 3 · How the Spec Defines It (Senior Developers)

ECMAScript Specification's "Non-Definition" Philosophy

The ECMAScript specification's approach to memory management can be summarized in one sentence: the spec defines when an object should be eligible for reclamation, but does not define how to reclaim it, or when.

Section 9.10 (Invariants of the Essential Internal Methods) makes clear:

The following invariants must be maintained by all ordinary and exotic objects accessible to ECMAScript code:

  • A non-extensible object must have a stable set of own properties. Once a property is defined, it cannot be removed, and its non-configurable status cannot change.

But for memory reclamation, the spec has no dedicated section. Garbage collection appears in the spec in two ways:

Way 1: Through the implicit definition of object reachability

The spec uses the concept of "reachable" (reachable) but provides no formal definition. In a note in Section 9.1:

An agent's executing thread executes a job on the agent's call stack. Once the job has completed, the thread removes that job from the stack and executes the next job.

This implies that when an object is no longer reachable from the "active call stack," its reclamation is permitted — but the exact timing is entirely up to the implementation.

Way 2: Through the formal definition of weak references

ECMAScript specification Section 26.1 defines WeakRef:

26.1.1 The WeakRef Constructor

WeakRef objects let you hold a weak reference to another object, without preventing that object from getting garbage-collected.

The target of a WeakRef is called its target or referent. A WeakRef is said to be live if its target has not been collected by the garbage collector.

This is one of the few places in the spec that directly mentions garbage collection. The spec's precise language here is:

26.1.3.2 WeakRef.prototype.deref()

  1. Let weakRef be the this value.
  2. Perform ? RequireInternalSlot(weakRef, [[WeakRefTarget]]).
  3. Return WeakRefDeref(weakRef).

9.12.3 WeakRefDeref(weakRef)

  1. Let target be weakRef.[[WeakRefTarget]].
  2. If WeakRefKey(weakRef) is in the [[KeptAlive]] List of the current agent, return target.
  3. If target is not empty, then a. Perform AddToKeptObjects(target). b. Return target.
  4. Return undefined.

This specification text reveals a key mechanism: the result of deref() depends not only on whether the object is alive, but also on the [[KeptAlive]] list — the spec guarantees that during the current "job" (the current synchronous execution block between microtask checkpoints), an object returned as non-undefined by deref() will not be collected. This is why WeakRef usage requires "handling the result of deref() within the same job."

The Spec's Definition of FinalizationRegistry

Section 26.2 defines FinalizationRegistry:

26.2.1 The FinalizationRegistry Constructor

FinalizationRegistry objects let you request a callback when a value becomes unreachable.

26.2.2.1 new FinalizationRegistry(cleanupCallback)

  1. If IsCallable(cleanupCallback) is false, throw a TypeError exception.
  2. Let finalizationRegistry be ? OrdinaryCreateFromConstructor(NewTarget, "%FinalizationRegistry.prototype%", « [[Realm]], [[CleanupCallback]], [[Cells]] »). ...
  3. Set finalizationRegistry.[[Cells]] to a new empty List.
  4. Return finalizationRegistry.

In Section 9.12.4's definition of CleanupFinalizationRegistry, the spec explicitly states when the callback is invoked:

9.12.4 CleanupFinalizationRegistry(finalizationRegistry)

This abstract operation is called by the host environment after an object is garbage-collected. It queues a microtask to invoke the cleanup callback.

NOTE: Cleanup callbacks are called as microtasks. The timing of when a cleanup callback is called is non-deterministic. It is possible for a cleanup callback to never be called.

This passage contains the most important fact about FinalizationRegistry: the cleanup callback may never be called. The spec explicitly states this, meaning any code that relies on FinalizationRegistry for necessary cleanup operations (such as closing file descriptors, releasing external resources) is incorrect.

Spec Semantics for WeakMap and WeakSet

Sections 24.3 and 24.4 define WeakMap and WeakSet respectively. The key property of both is described in Section 24.3.1.1:

The keys of a WeakMap are object values and registered symbols. A key is live if the key is reachable. An entry is live if its key is live.

Entries in a WeakMap may be collected if the key (for a WeakMap) or value (for a WeakSet) is not strongly referenced from anywhere.

This spec text tells us: WeakMap keys use weak references — if the key object is no longer strongly referenced anywhere else, GC can reclaim the key, and the corresponding entry disappears. But the spec doesn't specify when this entry disappears — it might be in the same GC cycle the key object is reclaimed, or it might be in the next one.

The spec particularly notes a key constraint (Section 24.3.4 note):

WeakMap objects are not enumerable. There is no mechanism for iterating over the keys or values of a WeakMap. If a WeakMap provided introspection methods, it would be possible to observe the garbage collector's behavior and the order in which finalizers are executed. Exposing this information would be a security vulnerability.

This explains why WeakMap has no size property, no keys() method, no forEach method — any enumeration capability would expose the GC's internal state, which the spec explicitly prohibits.

The Spec's Precise Definition of "Live References"

In the main spec, references stored in ordinary object properties, Variable Bindings, and Environment Records are all strong references. Strong references prevent the GC from reclaiming the target object.

References stored in WeakRef, WeakMap keys, and WeakSet values are weak references. Weak references do not prevent the GC from reclaiming the target object.

Section 9.12 (Managing the interaction between the garbage collector and WeakRef-like objects) gives the formal "keep-alive" semantics:

9.12.1 AddToKeptObjects(object)

Once an object has been added to [[KeptAlive]], it will not be collected until after the currently executing Job completes.

This means: during a synchronous task (Job) execution, even if a WeakRef's target is theoretically unreachable, it will not be collected during this task. This guarantees that the result of deref() is stable within the same synchronous block.


Level 4 · Boundaries and Traps (Everyone)

Trap 1: The Result of WeakRef.deref() Must Be Used Immediately

const registry = new FinalizationRegistry((key) => {
  console.log(`${key} was collected`);
});

let target = { data: new Array(100000) };
const ref = new WeakRef(target);
registry.register(target, 'myTarget');

target = null;  // remove strong reference, allow GC to collect

// Wrong usage: using the deref() result across an async boundary
async function processIfAlive() {
  const obj = ref.deref();  // deref at the start of the async function

  await someAsyncOperation();  // ← after this await, GC may have run

  if (obj) {
    // obj may be a reference to a collected object
    // The spec doesn't guarantee obj is valid after await
    // (though it often still is in V8 — the spec doesn't promise it)
    console.log(obj.data.length);  // unsafe
  }
}

// Correct usage: check and use within the same synchronous context
function processIfAlive() {
  const obj = ref.deref();
  if (obj) {
    // Used within the same synchronous block — spec guarantees it won't be GC'd here
    return obj.data.length;
  }
  return 0;
}

Why it's unsafe after await: await puts subsequent code into the microtask queue, and before that microtask executes, GC has an opportunity to run. The spec explicitly states that deref()'s "keep-alive" guarantee is only valid within the current Job (the synchronous execution block between microtask checkpoints).

Trap 2: FinalizationRegistry Is Not a Guaranteed Resource Cleanup Mechanism

// Serious error: relying on FinalizationRegistry to close a file descriptor
class FileHandle {
  constructor(path) {
    this.fd = openFile(path);  // open file, get file descriptor

    this._registry = new FinalizationRegistry(() => {
      closeFile(this.fd);  // relying on GC to close the file
    });
    this._registry.register(this, 'cleanup');
  }
}

// Problems:
// 1. If the program exits normally, the FinalizationRegistry callback may not be called at all
// 2. In Node.js, callbacks are not guaranteed to run on process exit
// 3. In long-running programs, GC may not occur for a long time, exhausting file descriptors
// 4. The spec explicitly states callbacks "may never be called"

// Correct approach: explicit resource management (ES2023 using declarations)
class FileHandle {
  constructor(path) {
    this.fd = openFile(path);
  }

  [Symbol.dispose]() {
    if (this.fd !== null) {
      closeFile(this.fd);
      this.fd = null;
    }
  }
}

// Using ES2023's using declaration (automatically calls Symbol.dispose)
{
  using handle = new FileHandle('/path/to/file');
  // ... use handle ...
}  // handle[Symbol.dispose]() is automatically called at block end

The explicit non-determinism in the spec: The note in Section 26.2.1 says:

NOTE: Registered objects are held weakly by the registry, so they may be collected even while the FinalizationRegistry object itself is still alive.

This means: even if the FinalizationRegistry object itself is still alive, registered objects can be reclaimed at any time — but the callback won't necessarily execute immediately.

Trap 3: WeakMap Key Type Restrictions and the Symbol.for Trap

// WeakMap keys must be objects or registered symbols
const wm = new WeakMap();

// Correct: object as key
wm.set({}, 'value');  // OK

// Correct: Symbol() unique symbol as key (ES2023+)
const sym = Symbol('my-symbol');
wm.set(sym, 'value');  // OK (V8 v12+, Node.js 20+)

// Wrong: string/number/null as key
wm.set('key', 'value');  // TypeError: Invalid value used as weak map key
wm.set(42, 'value');     // TypeError

// Fatal trap: Symbol.for() global symbols cannot be WeakMap keys
const globalSym = Symbol.for('shared');
wm.set(globalSym, 'value');  // TypeError: Invalid value used as weak map key

// Why? Symbol.for() creates a globally registered Symbol —
// its lifetime matches the application's lifetime (it can never be GC'd),
// so using it as a WeakMap key is meaningless (it would never be auto-cleaned)
// The spec explicitly prohibits this usage

Real case: A framework's plugin system used Symbol.for('plugin:' + name) as WeakMap keys to store plugin state, triggering TypeError: Invalid value used as weak map key in production because Symbol.for returns a global Symbol.

Trap 4: Performance Problems from Misunderstanding "Promotion"

// Pattern of frequently creating short-lived objects — this is correct,
// New Space GC handles it efficiently
function processItem(item) {
  const temp = { value: item.value * 2, timestamp: Date.now() };  // short-lived
  return transform(temp);  // temp dies immediately after function returns
}

// Dangerous pattern: "accidentally" extending the life of objects
// that should be short-lived, causing promotion to Old Space
const recentResults = [];  // global array

function processItem(item) {
  const temp = { value: item.value * 2, timestamp: Date.now() };
  recentResults.push(temp);  // temp held by global array — cannot be collected by New Space GC
  if (recentResults.length > 100) recentResults.shift();  // cleanup only happens then
  return transform(temp);
}

The cost of promotion to Old Space: Old Space GC (Mark-Sweep-Compact) runs far less frequently than New Space Scavenge, but produces longer pauses. Frequently creating "accidentally long-lived" objects accelerates the Old Space GC trigger rate, manifesting as 10-50ms pauses every few tens of seconds on high-concurrency servers.

Diagnosis: Use node --trace-gc to observe GC type and frequency:

node --trace-gc script.js
# Output:
# [GC] Scavenge 8.0 -> 7.9 MB, 0.8 ms           ← New Space GC, fast
# [GC] Mark-sweep 200.1 -> 150.0 MB, 45.2 ms    ← Old Space GC, slower

If you see very frequent Old Space GC (Mark-sweep), it means many objects are being accidentally promoted to Old Space.

Trap 5: Limitations of performance.measureUserAgentSpecificMemory()

// This API only works under these conditions:
// 1. Page must be cross-origin isolated (Cross-Origin-Opener-Policy: same-origin +
//    Cross-Origin-Embedder-Policy: require-corp)
// 2. Only works under https
// 3. Results are approximations, not precise memory usage

async function measureMemory() {
  if (!performance.measureUserAgentSpecificMemory) {
    console.log('Not supported or not in cross-origin isolated context');
    return;
  }

  const result = await performance.measureUserAgentSpecificMemory();
  console.log(result);
  // {
  //   bytes: 1234567,  // rough estimate, not precise
  //   breakdown: [
  //     { bytes: 123456, attribution: [...], types: ['Window'] },
  //     { bytes: 234567, attribution: [...], types: ['Worker'] },
  //   ]
  // }
}

// Use Node.js's process.memoryUsage() instead (more precise)
function getMemoryUsage() {
  const usage = process.memoryUsage();
  return {
    rss: usage.rss,              // total OS-allocated memory (including C++ parts)
    heapTotal: usage.heapTotal,   // total V8 heap size (allocated)
    heapUsed: usage.heapUsed,     // actual V8 heap usage (most useful)
    external: usage.external,     // memory used by C++ binding objects (e.g., Buffer)
    arrayBuffers: usage.arrayBuffers, // memory used by ArrayBuffers
  };
}

DevTools Heap Snapshot Workflow: Finding Real Leaks

Scenario: A single-page application's memory continuously grows during navigation; users report the page becoming sluggish after a few minutes of browsing.

Workflow Steps:

Step 1: Establish a baseline
- Open DevTools → Memory → Heap snapshot
- Click "Take snapshot" → this is Snapshot 1

Step 2: Trigger the suspected leak operation
- Navigate in the app: enter a page, then leave, repeat 5 times

Step 3: Take a second snapshot
- Click "Take snapshot" again → this is Snapshot 2

Step 4: Comparative analysis
- Select Snapshot 2
- In the dropdown, choose "Comparison" view
- Select Snapshot 1 as the comparison baseline

Step 5: Investigate leak candidates
- Sort by "# Delta" (count difference) — objects whose count only ever increases are leak candidates
- Sort by "Size Delta" (size difference) — find the objects consuming the most new memory
- Click a specific type to see the list of instances
- Click an instance to view "Retainers" (holder chain)

Step 6: Trace the retainer chain
- The Retainers chain shows "who is holding this object"
- Follow the chain upward until you find a root reference that shouldn't still be alive

Typical Retainer chain:
EventListener → Anonymous function → Closure → YourComponent → HTMLDivElement
This means: EventListener holds an anonymous function, whose closure holds YourComponent,
            which holds a DOM node that should have been destroyed already

Practical code: leak detection script for use with DevTools

// In Node.js, use --inspect and v8.writeHeapSnapshot()
const v8 = require('v8');
const path = require('path');

function takeSnapshot(label) {
  const filename = path.join('/tmp', `heap-${label}-${Date.now()}.heapsnapshot`);
  v8.writeHeapSnapshot(filename);
  console.log(`Heap snapshot written: ${filename}`);
  return filename;
}

// Simulating a leak detection flow
async function detectLeak(operation, iterations = 10) {
  // Warm up to exclude initialization memory
  await operation();

  // Force GC (requires --expose-gc flag)
  if (global.gc) {
    global.gc();
    global.gc();
  }

  const before = process.memoryUsage().heapUsed;
  const snapshot1 = takeSnapshot('before');

  for (let i = 0; i < iterations; i++) {
    await operation();
  }

  if (global.gc) {
    global.gc();
    global.gc();
  }

  const after = process.memoryUsage().heapUsed;
  const snapshot2 = takeSnapshot('after');

  const growth = after - before;
  const perOp = growth / iterations;

  console.log(`Memory growth: ${(growth / 1024 / 1024).toFixed(2)} MB`);
  console.log(`Per operation: ${(perOp / 1024).toFixed(2)} KB`);
  console.log(`Compare snapshots: ${snapshot1} vs ${snapshot2}`);

  // If perOp continuously grows (rather than approaching 0), there's a leak
  return { growth, perOp, snapshot1, snapshot2 };
}

// Run: node --expose-gc --inspect leak_detector.js

Configuring V8 heap size:

# Default Old Space max is ~1.4GB (64-bit system)
# If the service doesn't have enough memory, adjust it
node --max-old-space-size=4096 server.js  # 4GB

# To trigger GC earlier (lower GC trigger threshold, reduce peak memory)
node --max-old-space-size=512 server.js   # 512MB, more frequent GC, lower peak

# New Space size (affects GC frequency and object promotion threshold)
node --max-semi-space-size=64 server.js   # default ~16MB; increasing reduces New Space GC frequency

Chapter Summary

  1. The foundation of garbage collection is reachability, not reference counting — V8 starts from "roots" (stack variables, global objects) and traces all reachable objects. Any accidentally retained strong reference causes the GC to mistakenly classify an object as "alive" — this is the root cause of all memory leaks. The ECMAScript spec does not define the GC algorithm; it only defines the semantic boundaries of weak references.

  2. Generational GC's performance secret is "most objects die quickly" — New Space (Scavenge, < 1ms) handles short-lived objects; Old Space (Mark-Sweep-Compact, 1-50ms) handles long-lived ones. Accidentally "promoting" short-lived objects to Old Space (by retaining unnecessary references) accelerates Old Space GC trigger frequency, which is a common cause of pauses in high-concurrency servers.

  3. Incremental marking and concurrent marking compressed GC pauses from 400ms to under 5ms — but this isn't free: write barriers add extra overhead to every property write, and concurrent marking requires additional CPU cores. Understanding these costs helps you judge when extra GC performance optimization is worthwhile.

  4. WeakRef and FinalizationRegistry are spec-defined weak reference mechanisms with strict limitations — the result of deref() is only guaranteed stable within the current synchronous task; FinalizationRegistry callbacks are not guaranteed to be called; neither is appropriate as the primary mechanism for critical resource cleanup. ES2023's using declaration (Symbol.dispose) is the correct tool for resource management.

  5. The DevTools heap snapshot "Comparison + Retainers" workflow is the most reliable method for finding leak sources — take two snapshots (before and after operations), compare deltas to find objects whose count only grows, then trace the Retainers chain to find the root reference that shouldn't still be alive. Combined with node --expose-gc and manual GC triggering, you can eliminate the noise of "not yet GC'd memory" and precisely locate real leaks.

Rate this chapter
4.8  / 5  (3 ratings)

💬 Comments