
In a heap snapshot, start with objects that account for a large amount of retained memory, not merely objects with a large shallow size. Shallow size is the memory occupied by the object itself; retained size estimates the memory that would become collectible if that object were removed. A small cache entry can retain a large graph, while a large typed array may be harmless if it’s expected to remain alive.
Take a snapshot after the application reaches a known state, then inspect the snapshot’s retained-size and dominator views. Sort by retained size, expand suspicious entries, and look for the reference that keeps each subtree alive. Compare snapshots when possible: objects whose counts or retained sizes grow across equivalent workloads are stronger candidates than objects that are merely large once.
Detached DOM trees are a common source of surprises. The nodes are no longer attached to the document, but a JavaScript reference—often an event listener, closure, or cached component—can still keep them alive. Search for detached nodes, then follow their retaining paths rather than assuming the DOM itself is responsible.
const detached = document.createElement("div");
detached.innerHTML = "<button>temporary</button>";
const button = detached.querySelector("button");
button.addEventListener("click", () => {
console.log(detached.textContent);
});
// Removing a node from the document does not release it if
// another live object still references it.
Read the snapshot as a graph, not a list of suspicious names. A constructor label such as Array or Closure rarely identifies the bug by itself. The useful question is which live reference prevents the object from becoming unreachable, and whether that reference is expected to persist.
Now loading...
Trace retaining paths to their roots
A retaining path is a chain of references from a garbage-collection root to the object under inspection. Follow it from the object toward the root, checking each edge rather than relying on the displayed names. A path may pass through a closure, a listener registry, a component instance, and finally a global or a live document node. The first edge that crosses an expected lifetime boundary is often where the bug becomes actionable.
Common roots include global variables, the active execution stack, browser-managed structures such as the document, and native handles exposed to JavaScript. A path ending at a root does not prove the retention is wrong; it identifies why collection cannot occur. Determine which owner should have released the reference and what event or lifecycle transition was supposed to do it.
When the snapshot offers several retaining paths, inspect more than the shortest one. A short path may be incidental, while another path reveals the long-lived owner. Dominators help narrow the search: if one object dominates a large subtree, every path to those objects passes through it. Releasing that dominator can make the whole subtree collectible.
const panel = document.querySelector("#panel");
function mount() {
const state = { records: loadLargeRecords() };
function onResize() {
panel.textContent = state.records.length;
}
window.addEventListener("resize", onResize);
return function unmount() {
window.removeEventListener("resize", onResize);
};
}
const unmount = mount();
// If that is never called, the listener can retain onResize,
// which retains state and its records.
Closures retain the bindings they need, not an abstract copy of the surrounding function. In a snapshot, inspect the closure’s context and follow references into captured state. A callback stored in a long-lived registry can therefore keep an entire component graph alive even after the component’s DOM nodes have been removed.
Distinguish strong references from weak ones when reading the path. A weak map entry or weak reference does not, by itself, keep its target alive. If the target remains retained, look for another strong path; heap tools may show weak edges for inspection even though they do not prevent collection. Also check whether the object is still reachable through active stack variables, since a temporary reference in a debugging session can distort the result.
Record the exact chain that explains retention, including the owning object and the field or registration that forms each edge. For example, Window → event listener → closure context → component state → cached records points to a different repair than Document → attached node → expando property → cache. The path turns a heap observation into a specific ownership relationship.
Separate reachable objects from useful objects
Reachability answers whether the garbage collector can reclaim an object. It does not answer whether the application still needs it. A cache can be reachable and correct; a detached component can be reachable and obsolete. Classify each suspicious object by its owner, intended lifetime, and role in current behavior before changing references.
For caches, check whether entries are reused and whether the cache has a limit or eviction policy. A cache whose size rises with every distinct input is not bounded merely because each entry is useful once. Inspect both entry count and retained size under representative workloads, including inputs that are unlikely to repeat.
class ResultCache {
constructor(limit) {
this.limit = limit;
this.entries = new Map();
}
get(key, compute) {
if (this.entries.has(key)) {
const value = this.entries.get(key);
this.entries.delete(key);
this.entries.set(key, value);
return value;
}
const value = compute();
this.entries.set(key, value);
if (this.entries.size > this.limit) {
const oldestKey = this.entries.keys().next().value;
this.entries.delete(oldestKey);
}
return value;
}
}
Estimate usefulness against the cost of retention. A frequently reused parsed representation may justify its memory; a rarely reused copy of a large response may not. Measure hit rate, entry age, and bytes retained where practical. If a cache has no observable benefit, removing it’s often safer than inventing a complicated invalidation scheme.
Check whether retained objects correspond to active application state. A list of records retained by the current screen may be expected, while records from screens that have been closed may indicate an ownership error. Compare the heap with visible UI and application state, and verify that counts fall after the corresponding feature is dismissed.
Temporary reachability can mislead inspection. A console variable, paused stack frame, debugger watch expression, or test harness may hold a reference that production code does not. Clear console references, resume execution, and take a fresh snapshot after the runtime has had an opportunity to collect unreachable objects.
Weak references are appropriate only when the program can tolerate the target disappearing at any time; they are not a general replacement for explicit ownership. Prefer a bounded cache when the requirement is predictable memory usage, and explicit cleanup when the requirement is a defined lifetime.
const subscriptions = new Map();
function subscribe(owner, callback) {
const unsubscribe = eventBus.on("update", callback);
subscriptions.set(owner, unsubscribe);
}
function release(owner) {
const unsubscribe = subscriptions.get(owner);
if (unsubscribe) {
unsubscribe();
subscriptions.delete(owner);
}
}
Before labeling a reachable object a leak, state what should release it and under which transition. Then check whether that transition occurred and whether the retained object still serves an observable purpose. This separates intentional process-lifetime state from stale state whose owner has outlived its usefulness.
Reproduce leaks with controlled workloads
A leak is a trend across equivalent work, not a large heap at one instant. Define a workload with a known starting state, a fixed set of inputs, and a cleanup step. Run it repeatedly without changing the amount of work. If the number of retained objects or their retained size rises after each cycle, inspect the objects that persist from earlier cycles.
Separate one-time initialization from the repeated operation. Warm up the application first so code compilation, module loading, and expected caches do not dominate the comparison. Then take a baseline snapshot, run a fixed number of cycles, allow pending tasks to finish, and take another snapshot. Repeat with the same cycle count; compare object counts and retaining paths, not only total heap size.
async function runCycle(recordId) {
const view = await openRecord(recordId);
await view.render();
await view.close();
}
async function settle() {
await new Promise(resolve => setTimeout(resolve, 0));
await new Promise(resolve => requestAnimationFrame(resolve));
}
async function runWorkload(cycles) {
for (let i = 0; i < cycles; i++) {
await runCycle(i % 20);
}
await settle();
}
Use a small, repeatable input set when testing component lifetimes. Reusing the same records can reveal whether each open-and-close cycle leaves behind another component, listener, or DOM subtree. Then vary the inputs in a separate test: an unbounded cache may appear stable when every cycle reuses the same keys but grow when each cycle introduces new keys.
Keep measurement points consistent. In browser developer tools, take snapshots after the same workload phase and after cleanup has completed. If the environment supports explicit garbage collection, use it consistently before each snapshot; otherwise, avoid treating short-term heap fluctuations as proof of a leak. A forced collection is a diagnostic aid, not a substitute for correct ownership.
async function measureSeries(cycleCounts) {
for (const count of cycleCounts) {
await runWorkload(count);
if (typeof globalThis.gc === "function") {
globalThis.gc();
}
console.log({
cycles: count,
heapUsed: performance.memory?.usedJSHeapSize
});
}
}
measureSeries([20, 40, 60, 80]);
performance.memory is not available in every browser and reports aggregate values, so use it to identify a trend rather than locate the retaining reference. Heap snapshots provide the object-level evidence. Compare equivalent snapshots by constructor and retained size, then follow the retaining paths of objects whose counts grow with the number of workload cycles.
Make the workload deterministic enough that expected live state is easy to distinguish from stale state. Close views, remove listeners, clear timers, and drain queued work before measuring. Avoid leaving inspected objects in console variables or paused debugger frames, since those references can change what the snapshot retains.
For a suspected leak tied to a specific lifecycle, test both sides of the boundary: run the operation and cleanup repeatedly, then run the operation without cleanup as a positive control. If both cases show the same growth, the workload or measurement is not isolating the cleanup path. If only the cleaned-up case plateaus while the control grows, inspect the control’s retaining paths to identify the ownership edge the cleanup must remove.
Verify fixes with repeated snapshots
A fix is verified when the same workload no longer accumulates retained objects across repeated cycles. Take a baseline after warm-up, run a fixed number of operations and their cleanup, let queued work settle, then take a snapshot. Repeat with the same workload and compare object counts, retained sizes, and retaining paths. A lower heap total in one snapshot is not enough; allocation and collection timing can move that number without changing the underlying ownership.
Look for the specific population implicated by the bug. If each closed view previously left one component instance and its records reachable, those counts should stop increasing after the fix. Objects that remain may be legitimate shared state, so inspect their paths and verify that the stale owner or registration is gone.
async function exercise(count, recordId) {
for (let i = 0; i < count; i++) {
const view = await openRecord(recordId);
await view.render();
await view.close();
}
await new Promise(resolve => setTimeout(resolve, 0));
await new Promise(resolve => requestAnimationFrame(resolve));
}
await exercise(25, "repeatable-record");
Use identical snapshot conditions before and after the change. Warm up the same code paths, use the same input set, finish the same cleanup operations, and avoid keeping inspected objects in the console. Where explicit garbage collection is available, invoke it at the same measurement points in both runs. Compare snapshots at several cycle counts; a fixed amount of expected state may remain, but the suspect population should approach a plateau rather than rise with each batch.
Retaining paths should change in a way that matches the repair. If removing a listener was the fix, the path through the listener registry should disappear after the view closes. If bounding a cache was the fix, its entry count and retained size should stop growing beyond the limit. A changed total heap without the corresponding change in these paths can mean that the leak is still present or that a different allocation pattern obscures the measurement.
async function snapshotSeries(batchSizes) {
for (const size of batchSizes) {
await exercise(size, "repeatable-record");
if (typeof globalThis.gc === "function") {
globalThis.gc();
}
console.log({
batchSize: size,
heapUsed: performance.memory?.usedJSHeapSize
});
}
}
await snapshotSeries([25, 25, 25, 25]);
For a stronger comparison, run equivalent batches from the same initial state and record counts for the constructors or object groups involved in the bug. Compare the growth between snapshots, not just their absolute values. A repaired lifecycle may still allocate heavily during each batch; the relevant result is that objects from completed batches become collectible and do not accumulate.
Keep a regression workload that exercises the failure transition: create the owner, attach the callback or resource, then destroy the owner and verify that the associated state is released. Re-run it after changes to lifecycle code, event registration, caching, or component ownership, since a fix can be undone by a later path that bypasses cleanup.
Source: https://www.jsfaq.com/how-to-identify-retained-objects-in-javascript-memory/


