JavaScript memory and GC
How V8's Orinoco GC works: generations, tri-color marking, concurrent and incremental phases, write barriers, and the WeakRef and FinalizationRegistry APIs.
The problem GC solves
Manual memory management is fast and dangerous. You forget a free and leak memory. You free twice and corrupt the heap. You free too early and get use-after-free, the most common source of security bugs in C++. JavaScript was designed to be safe for non-experts to write, so it had to have automatic memory management from day one.
Garbage collection is the implementation choice. The runtime tracks which objects are still reachable from roots and reclaims the rest. The hard part is doing this without freezing the page.
Generational hypothesis
Empirically, in almost every program, most allocated objects die within microseconds of being created. Temporary strings, intermediate objects from a chain of operations, closure environments that outlive their function by a single tick. A small fraction of allocations live for the lifetime of the program: caches, configuration, the DOM tree, long-lived event handlers.
The generational hypothesis says: separate the young objects from the old ones, and GC them with different strategies. Young objects are best handled by copying (cheap if live set is small). Old objects are best handled by mark-sweep-compact (expensive but rare).
V8 implements this with a young generation and an old generation. Young is small (1-8MB), allocated by bump-pointer (just move a pointer forward), and collected by a copying scavenger when it fills. Old is large (1.4GB default), allocated more carefully, and collected by mark-compact.
Young generation: the scavenger
The young generation is split into two equal-size semi-spaces: to-space (where new allocations go) and from-space (initially empty). When to-space fills:
- Stop the main thread.
- Trace from roots, but only follow references into to-space.
- Copy each live young object into from-space.
- Update all references to point to the new locations.
- Swap the labels: from-space becomes to-space, old to-space is now empty.
An object that survives two scavenges (configurable, default 2) gets promoted to old generation instead of copied. This is the "old enough to live" heuristic.
Scavenge takes 1-5ms typically. The cost is proportional to the live set, not the total young generation size, because dead objects are not touched. If 95% of young objects die each cycle (typical), the GC only copies the surviving 5%.
Old generation: mark-compact
The old generation uses a tri-color mark-sweep-compact algorithm.
Tri-color marking:
- White: not yet visited (potentially dead).
- Gray: visited, but children not yet visited.
- Black: visited, children visited.
Start: roots are gray. Loop: pick a gray object, mark it black, mark its white children gray. End: no gray objects. All white objects are dead.
After marking, the sweep phase walks the heap and frees white objects. Then compaction moves surviving objects to be contiguous, reducing fragmentation and allowing future bump-pointer allocation.
Naively, this requires a stop-the-world pause proportional to the live old-gen size. For a 500MB heap that could be hundreds of milliseconds. Unacceptable for an interactive app. So V8 splits the work across multiple modes.
Concurrent and incremental marking
Concurrent marking: most of the mark phase runs on background threads while JS keeps executing on the main thread. The main thread does small amounts of marking work (incremental) and at the end performs a short final-marking pause to handle any references that changed during concurrent marking.
The challenge with concurrent marking is the "lost object" problem. If the mutator (your JS code) writes a reference from a black object to a white object, the white object might never be marked, and the GC will collect it while it is still live. The solution is the write barrier.
A write barrier is code injected at every property store that updates GC bookkeeping. When you write black.field = white, the barrier either marks white gray (Dijkstra-style) or marks black gray again (Steele-style). V8 uses a Dijkstra write barrier. The cost is a few extra instructions per store, but it allows the GC to run concurrently without missing references.
Parallel scavenging and tasks
Beyond concurrent marking, V8 parallelizes work where possible. Scavenge has multiple worker threads scanning roots and copying objects. The Orinoco system as a whole tries to keep main-thread pause time under 1ms per GC interaction by moving as much as possible off-thread.
The result, on a typical Chrome session: GC pauses are usually under 5ms. The user does not notice them. Occasionally an old-gen compaction will take 50-100ms and you will see a frame drop. The team optimizes for the 99th percentile pause, not the average.
Roots
Every GC starts by finding roots: objects that are definitely alive because something external holds them. The roots are:
- The call stack (every frame's locals are roots).
- The global object (
windowin browsers,globalThisin modules). - Any object reachable from a registered API handle (Node addons, embedder APIs).
- The DOM tree (each node holds references via embedder logic).
- Currently active timers and event listeners.
- The microtask queue (any pending callback is a root).
If you can trace from a root to an object via references, the object is alive. Otherwise it is dead.
Common leak patterns
Detached DOM. You remove a node from the DOM but still hold a reference to it from JS. The node and all its descendants stay alive. Common in single-page apps where you remove a panel but the close-button handler still has a reference.
Closures pinning scope. A closure references its entire enclosing scope, not just the variables it reads. If an event handler holds a closure that references a 100MB image in the enclosing function's locals, the image stays alive as long as the handler does.
Map with object keys. Map holds strong references to its keys. If keys are objects you do not control, the map prevents them from being collected. Use WeakMap if the key should be eligible for GC when the rest of the program drops it.
Long-lived caches. Anything that grows unboundedly leaks. LRU caches with size limits do not.
// Leak: each call adds to the array, never cleans up
const log = [];
function record(event) { log.push(event); }
// Fix: bounded
const log = [];
function record(event) {
log.push(event);
if (log.length > 1000) log.shift();
}Timers with closures. setInterval(() => doStuff(bigObject), 1000) holds bigObject until you clearInterval. Easy to forget when navigating away from a SPA route.
WeakRef and FinalizationRegistry
ES2021 added two APIs for advanced memory management:
WeakRef holds a weak reference to an object. The GC can still collect the object. You access it via .deref(), which returns the object or undefined if it has been collected.
FinalizationRegistry lets you register a callback to be invoked when an object is garbage collected.
Both are best-effort and not guaranteed. The spec explicitly allows engines to never collect a target or never invoke a finalizer. Do not use them for correctness. Use them for caching and observability.
const cache = new Map();
function get(key) {
const ref = cache.get(key);
const obj = ref?.deref();
if (obj) return obj;
const fresh = expensiveCompute(key);
cache.set(key, new WeakRef(fresh));
return fresh;
}The trap: the WeakRef itself stays in the cache map. You need a FinalizationRegistry to clean up the dead entries, and even then the timing is unpredictable.
Tools for diagnosing leaks
Chrome DevTools Memory panel has three tools:
- Heap snapshot. Captures the entire heap at one moment. Diff two snapshots to find allocations that survived.
- Allocation instrumentation on timeline. Records every allocation with its stack trace. Find what is allocating in a tight loop.
- Allocation sampling. Statistical sampling, lower overhead, suitable for production-like loads.
The "Comparison" view between two snapshots is the most useful. Take a snapshot, do the action you suspect leaks (open a panel, navigate a route), close it, take another snapshot, compare. The "Delta" column shows what is new.
For each leaked object, the "Retainers" panel shows the chain of references keeping it alive. The bottom of the chain is the root. Follow the chain and you find the variable you forgot to clean up.
In Node, --inspect plus Chrome DevTools gives you the same tools. node --heap-prof writes V8 heap snapshots to disk for later analysis.
--max-old-space-size and OOM
Node's default old-gen limit is around 1.4GB on 64-bit. Larger heaps need --max-old-space-size=4096 (or higher). The GC keeps the heap under this limit; when it cannot, the process aborts with "JavaScript heap out of memory."
The temptation is to raise the limit. The right response is to find what is using the memory. A growing heap is almost always a leak or an unbounded cache, not a legitimate need.
The mental model
You do not control GC. You control reachability. Anything reachable from a root stays alive. The job of writing memory-safe JS is making sure things become unreachable when they should.
The corollary: a memory leak in JS is always a reference you did not realize you were holding. Find the reference, drop it, problem solved. The DevTools retainer view is how you find it.
Learn more
- Article
- Article
- Article
- Docs