Photo by PJH on Unsplash

On this page

This chapter follows Breaking up long tasks. It explains how memory is allocated and reclaimed. The next chapter, Finding memory leaks, uses that model to track down memory that is not reclaimed. The collector details are V8’s, from Chrome and Node; SpiderMonkey and JavaScriptCore use the same main ideas.

What lives on the heap

The language has no notion of a stack or a heap. Where a value is stored is the engine’s decision, and the common rule of thumb — primitives on the stack, objects on the heap — does not describe what engines do.

In V8, nearly everything is a heap object: objects, arrays, functions, closures’ environments, strings, BigInt values, and numbers that are not small integers. A stack slot or an object field holds either a pointer to a heap object or, for small integers (Smis), the integer itself encoded in the slot. const n = 42 stores no heap object at all; const x = 0.5 usually points to a boxed heap number, though optimized code keeps raw doubles in registers when it can.

Strings are heap objects even though they are primitives, and engines have several representations for them: flat strings, concatenations stored as a pair of pointers until they are needed, and slices that point into a larger string. A short substring of a very large string can keep the large one alive.

Some memory is outside the JavaScript heap entirely. The bytes of an ArrayBuffer, Node Buffer contents, decoded images, and DOM nodes are allocated by the host or the engine’s embedder. The JavaScript heap holds small objects that refer to them.

Reachability

A tracing collector does not track when memory is “done”. It asks a simpler question: which objects can the program still reach?

The starting points are the roots:

  • The global object and everything declared at the top level of scripts and modules.
  • Variables and temporaries in every frame on the call stack, and in suspended generators and async functions.
  • References the host holds: registered event listeners, pending timers, the DOM tree of a document, promises waiting on the host, and handles held by native code.

An object is live if a chain of references leads to it from a root. Everything else is garbage, however many references it has. Cycles do not matter:

Diagram 1
flowchart LR
  root(["Roots: globals, stack, host handles"]) --> app["app"]
  app --> users["users Map"]
  users --> u1["user 1"]
  old1["old session"] --> old2["old socket wrapper"]
  old2 --> old1

The old session and its socket wrapper refer to each other, but nothing reachable refers to either, so both are collected. Reference-counting collectors, used in some older browsers’ DOM implementations, leaked such cycles. Tracing collectors do not.

The question for every leak is therefore which path from a root still reaches the object, not which object forgot to release it.

Tracing: mark, sweep, compact

A full collection of V8’s old generation, the Mark-Compact collector, works in three phases:

  1. Mark. Starting from the roots, follow every reference and mark each object reached. When there is nothing left to follow, every unmarked object is garbage.
  2. Sweep. Walk memory pages and add the space of unmarked objects to free lists, which later allocations reuse.
  3. Compact. Some pages end up mostly empty, with live objects scattered between gaps. The collector moves the live objects off those pages, updates every pointer to them, and releases the pages. This fights fragmentation, where free memory exists but only in gaps too small to use.

The cost of marking is proportional to the number of live objects, since only those are visited. A large heap of live data is expensive to collect even if little garbage exists.

Generations

Most objects die young. Temporary arrays from map, strings built while formatting, objects returned and immediately destructured: they become garbage within microseconds. This is the generational hypothesis, and V8 splits the heap to exploit it:

  • The young generation is small, a few megabytes to tens of megabytes. New objects are allocated there by bumping a pointer, which is about as cheap as allocation gets.
  • The old generation holds objects that have survived. It is large and collected less often.

The young generation is collected by the Scavenger, a copying collector. It divides the young space into two halves. When the active half is full, it copies the live objects to the other half, and the whole first half becomes free at once. Dead objects cost nothing, because they are never visited. Objects that survive two scavenges are promoted to the old generation.

Scavenging only the young generation requires knowing which young objects are referenced from old ones without scanning the whole old generation. A write barrier, a small piece of code the engine runs on each pointer store into a heap object, records old-to-young references in a remembered set, and the scavenger treats those as extra roots.

Node shows both collectors at work with --trace-gc:

Terminal window
node --trace-gc app.js
[43582:0x41bda000] 12 ms: Scavenge 4.6 (5.7) -> 3.7 (6.7) MB, pooled: 0 MB, 0.17 / 0.00 ms (average mu = 1.000, current mu = 1.000) allocation failure;
[43648:0x3c9fa000] 165 ms: Mark-Compact 112.9 (177.8) -> 100.7 (229.3) MB, pooled: 0 MB, 18.32 / 0.00 ms …

Each line shows the heap in use before and after the collection, with the committed size in parentheses, and the pause on the main thread. In this run, a scavenge took 0.17 ms and a mark-compact of about 110 MB took 18 ms. “Allocation failure” is the normal reason for a scavenge: the young generation was full.

Keeping pauses short

A collector that stops the program for the whole collection would freeze pages for tens of milliseconds at a time. V8’s collector does most of its work in ways that avoid that:

  • Parallel: the main thread and helper threads collect together during a pause, which shortens it. Scavenges are parallel.
  • Incremental: marking is split into small steps interleaved with JavaScript execution.
  • Concurrent: helper threads mark and sweep while JavaScript keeps running on the main thread. Write barriers keep the marking correct while the program changes references.
  • Idle-time: Chrome schedules collection work in idle periods between frames.

Some work, such as the final marking step and parts of compaction, still pauses the main thread. A program that allocates heavily in a tight loop gives the collector more to do, and the pauses show up in profiles as time attributed to GC. The fix is the same as for other long tasks: allocate less in hot loops, for example by reusing arrays instead of creating new ones per iteration.

Heap limits

The heap has a maximum size. Exceeding it is not a recoverable error. In Node, the process ends with:

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory

Node picks the old-generation limit from the available memory. Raise or lower it, in megabytes, with:

Terminal window
node --max-old-space-size=4096 build.js

In containers, set the limit below the container’s memory limit, so the process fails with a clear heap error instead of being killed by the operating system. Raising the limit helps a process that genuinely needs more memory, such as a large build; for a leak, it only delays the crash.

process.memoryUsage() shows where memory goes:

Field Meaning
rss Total memory the process holds, including code and native allocations
heapTotal Memory reserved for the JavaScript heap
heapUsed Memory used by live and not-yet-collected objects in the heap
external Memory of C++ objects tied to JavaScript objects, such as buffers
arrayBuffers Memory of ArrayBuffer and Buffer contents, included in external

A growing rss with a stable heapUsed points to native or buffer memory, not JavaScript objects. Browsers have per-tab limits too, which surface as a crashed tab rather than an exception.

Weak references

Normally a reference keeps its target alive. A few structures hold references that do not:

  • WeakMap and WeakSet hold their keys weakly. An entry disappears when its key object is collected, and a WeakMap value is kept alive only as long as its key is. This makes them the right tool for attaching data to objects you do not own. See Map and WeakMap.
  • WeakRef holds one object weakly. deref() returns the object, or undefined after it has been collected.
  • FinalizationRegistry calls a callback some time after a registered object has been collected.
src/image-cache.js
const cache = new Map();
export function getCached(key, create) {
const cached = cache.get(key)?.deref();
if (cached) return cached;
const value = create();
cache.set(key, new WeakRef(value));
return value;
}

Within one task, deref() keeps returning the object once it has returned it, so you do not need to check it again in the same synchronous code. Across tasks, it may become undefined at any point.

WeakRef and FinalizationRegistry expose the collector’s timing, which the language otherwise hides, and that timing differs between engines, versions, and runs. A finalization callback may run late, or never — for example, when the page is closed first. Use them for caches and diagnostics, never for logic that must run.

Deterministic cleanup with using

The collector reclaims memory. It does not close files, release locks, end database transactions, or stop subscriptions, and it gives no guarantee of when — or whether — anything tied to an object’s collection happens. Resources other than memory need explicit cleanup.

The traditional way is try/finally. Explicit resource management adds a declaration for it: a using binding calls the value’s [Symbol.dispose]() method when the block exits, whether normally or by an exception.

src/lock.js
function acquireLock(name) {
console.log(`locked ${name}`);
return {
[Symbol.dispose]() {
console.log(`released ${name}`);
},
};
}
{
using lock = acquireLock('report');
console.log('writing report');
}
// locked report
// writing report
// released report

await using does the same with [Symbol.asyncDispose](), and DisposableStack collects several resources to dispose in reverse order. The syntax runs in Node 24 and current Chromium-based browsers; check your other targets, or transpile it, before relying on it.

Next: Finding memory leaks.

Check your understanding

Question 1 of 8

0 of 8 checked

  1. 1. Which values are typically heap objects in V8?Select all that apply.

Search articles

Type a word to search published articles.

Open the search page