What Counts as a GC Root in V8
Every retainer path in a heap snapshot ends at something labelled (GC roots), (Global handles), (Handle scope) or Window / global — and knowing which one tells you where the reference that keeps your object alive really lives. This guide from How Mark-and-Sweep Garbage Collection Works, in JavaScript Memory Fundamentals & Runtime Mechanics, explains what V8 treats as roots, how they appear in DevTools, and which root categories are behind most real leaks.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
Path ends at Window or global |
Object reachable from a global variable or module | Find the named global/module binding on the path | Leak owner identified in your code |
Path ends at (Global handles) |
Native code (browser or Node internals, addon) holds a persistent handle | Look one level down for the listener, timer or callback registered | Registration found and removed |
Path ends at (Handle scope) or (Stack roots) |
Object referenced by currently executing code | Retake snapshot when idle; it is not a leak | Avoids chasing transient references |
Path ends at (Document DOM trees) or (Detached DOM trees) |
DOM wrappers kept by the embedder | Follow the detached DOM workflow | Detached nodes released |
Retainer path passes through (Internalized strings) or (Builtins) |
Engine-internal roots holding shared data | Ignore; look for a different path | Focus stays on application code |
Root Cause: Reachability Starts Somewhere
A tracing garbage collector decides liveness by reachability: an object is alive if it can be reached by following references from a root. Roots are the references the collector must assume are in use no matter what, because they belong to running code or to the host environment. Everything reachable from them is marked; everything else is garbage. Understanding roots is the difference between reading a retainer path and understanding it, as the general walkthrough in reading the Retainers panel shows.
V8’s roots fall into a few families. The global object and script/module contexts — window in browsers, globalThis and module environments in Node.js — hold every global variable and top-level binding. Stack roots are references held in the frames of currently executing functions (and in registers); they are transient and vanish when those functions return. Handles are references that native (C++) code holds to JavaScript objects: local handles live in handle scopes and last for the duration of a native call, while persistent or global handles last until the native code releases them. Engine-internal roots include built-in objects, the string table of internalised strings, compilation caches and similar structures.
Embedders add their own. In Chrome, Blink holds references to JavaScript wrappers for DOM nodes that are in a document, to event listeners registered on DOM targets, to pending callbacks (timers, requestAnimationFrame, promise jobs, observers) and to objects referenced by in-flight operations. In Node.js, libuv handles and the event loop keep timers, sockets, servers and their callbacks alive, and native addons can hold persistent handles.
Most real leaks end at one of three places: a global or module binding (a cache, a registry — see module-level caches and global singleton leaks), a handle held by the host for a registration you made (an event listener, an interval, an observer, a socket), or the DOM (a detached subtree referenced by JavaScript). Stack and handle-scope roots are almost never leaks; they indicate you captured the snapshot while something was running.
Step-by-Step Fix
- Capture a snapshot when the app is idle. Let pending work finish, then take the snapshot in DevTools → Memory. Verification: stack and handle-scope roots account for few retainers, so paths reflect lasting references.
- Select a leaked object and follow the shortest path to its root. Expand the first child at each level of the Retainers pane. Verification: you reach an entry marked as a root category, such as
Window,(Global handles)or(Document DOM trees). - Classify the root. Global/module: a binding in your code. Global handles: a registration with the host. DOM trees: an element referenced through the DOM. Stack/handle scope: transient. Engine internals: ignore. Verification: you know which family ends the path.
- For global roots, find the binding. Read the entry just below the root — a property name on
Windowor a variable in a module context. Verification: you can open the file that declares it. - For handle roots, find the registration. The entries below
(Global handles)usually include a listener, aV8EventListener/InternalNode, a timer or an observer callback; open the function link. Verification: you locate theaddEventListener,setInterval,observeoron()call. - Remove the reference and re-snapshot. Clear the binding, deregister the handle, or release the DOM reference. Verification: the object no longer appears; its former path is gone.
Command and Code Reference
Use case: the two root families that cause most leaks, with fixes.
// Global/module root: the cache binding keeps every report alive
export const appCache = new Map(); // module context → root
export function openReport(id) {
const report = buildReport(id);
appCache.set(id, report); // never evicted
return report;
}
// Fix: bound it (see LRU patterns) or delete on close: appCache.delete(id)
// Host-handle root: window holds the listener; the listener's closure holds the chart
export function mountChart(el) {
const chart = createChart(el);
const onResize = () => chart.resize(); // captures chart
window.addEventListener('resize', onResize); // host keeps onResize alive
return () => {
window.removeEventListener('resize', onResize); // releases the handle root path
chart.destroy();
};
}
Use case: list handle-rooted timers and sockets in Node.js while debugging. Active resources are held by the event loop and act as roots for their callbacks.
// What is keeping the process (and its callbacks) alive right now?
// process.getActiveResourcesInfo() lists resource types such as 'Timeout', 'TCPSocketWrap'
const counts = process.getActiveResourcesInfo().reduce((acc, type) => {
acc[type] = (acc[type] || 0) + 1;
return acc;
}, {});
console.table(counts); // a growing 'Timeout' count usually means intervals never cleared
Verification and Regression Prevention
A root-driven leak is fixed when the snapshot contains no path from that root family to the leaked object and repeated scenarios keep the object count flat. For host-handle leaks, also check the host’s own bookkeeping: DevTools → Elements → Event Listeners for window and document, or process.getActiveResourcesInfo() in Node, should show counts that do not grow with repetitions.
For prevention, treat every registration with a host — listeners, timers, observers, sockets, subscriptions — as a resource with an owner and a release path, and every module-level collection as needing a bound. Automated leak tests that print retainer traces, such as finding leaks with Memlab scenarios, make root categories visible in CI output, so a new path ending at (Global handles) is easy to spot in review.
Edge Cases and Gotchas
Weak handles are not roots
Hosts also keep weak handles — for example, a DOM wrapper that can be recreated on demand, or objects tracked by FinalizationRegistry. These do not keep objects alive and appear as weak edges in snapshots. Only strong handles count as roots.
DevTools itself adds roots
Objects you inspected in the Console, $0 element references and variables stored with Store as global variable are retained by DevTools. Clear the Console and avoid inspecting leaked objects before taking the snapshot you intend to analyse.
Microtasks and pending callbacks
Queued promise jobs, queueMicrotask callbacks and pending setTimeouts are held by the host until they run. A snapshot taken while many are pending shows them as rooted, even though they will release once executed.
Native addons in Node.js
Addons that create persistent handles must release them explicitly. A path ending at (Global handles) with no JavaScript registration in between often points to an addon or a native module; check its documentation for a close or dispose method.
Frequently Asked Questions
What are GC roots in JavaScript?
They are the starting points of garbage collection: references the collector assumes are live, such as the global object and module bindings, references on the current call stack, and handles that the browser or Node.js holds to JavaScript objects. Anything reachable from a root is kept; everything else is collected.
Is an object referenced by (Global handles) always leaked?
No. Global handles are legitimate for active registrations — a listener that should still be listening, an active timer. It becomes a leak when the registration outlives the component or feature that created it. Check whether the thing that registered it still exists.
Why do stack roots appear in my snapshot?
Because code was running when the snapshot was taken, or DevTools was paused at a breakpoint. Those references disappear when execution continues. Take snapshots while the page is idle and the debugger is not paused.
Are closures roots?
No. A closure is an ordinary heap object; it keeps its captured context alive only while something reachable references the closure. It is usually reachable through a root such as a listener registration or a global binding, which is what you need to find.
Related
- How Mark-and-Sweep Garbage Collection Works — the parent topic
- Incremental and Concurrent Marking in V8’s Orinoco GC — how marking proceeds from these roots
- What Distance Means in a Heap Snapshot — distances are measured from these roots
- JavaScript Memory Fundamentals & Runtime Mechanics — the section overview