A panel is removed from the page, but its resize handler still runs. The DOM looks clean, so why does the browser keep the old panel in memory?
Removing an element from the document and making it unreachable are different operations. JavaScript's garbage collector can reclaim objects that are no longer reachable. It cannot reclaim an object while your application still holds a usable reference to it, even if that reference is an accident.
This guide uses a small browser reproduction to find one such reference. The goal is not to force garbage collection or make a memory chart look flat. It is to answer a specific question: what is still retaining an object after the UI that created it has been removed?
A removed DOM node can still be reachable
Think of memory as a graph. JavaScript values, functions, and browser objects can reference one another. The garbage collector traces from roots, including active execution state and objects held by the runtime. An unreachable object becomes eligible for collection. "Eligible" does not mean it will be collected immediately.
A listener registered on window illustrates the problem:
window
-> resize listener
-> callback function
-> captured panel element
-> its child nodes
Calling panel.remove() breaks the element's connection to the document tree, not the listener's reference to panel. The callback can still run and update that element. The garbage collector is working correctly when it retains the node.
This is the distinction behind MDN's memory management explanation: collection is based on reachability, not whether a developer still intends to use an object. Cyclic references alone are not necessarily leaks; an unreachable cycle can be collected.
If the behavior of a callback retaining local variables is unfamiliar, the separate guide to JavaScript closures and bindings explains the language-level mechanism.
Reproduce the leak in a blank browser page
Open a blank HTML page in Chrome, open DevTools, and paste this entire snippet into the Console. It deliberately contains a cleanup bug. Use it only for this reproduction.
(() => {
let resizeCalls = 0;
function mount({ cleanup }) {
const panel = document.createElement("section");
panel.className = "memory-demo-panel";
panel.textContent = "A temporary panel";
document.body.append(panel);
function onResize() {
resizeCalls += 1;
panel.dataset.width = String(window.innerWidth);
}
window.addEventListener("resize", onResize);
return () => {
panel.remove();
if (cleanup) window.removeEventListener("resize", onResize);
};
}
window.memoryDemo = {
run({ cleanup, times = 20 }) {
for (let i = 0; i < times; i++) mount({ cleanup })();
},
get resizeCalls() {
return resizeCalls;
},
};
})();
mount() creates a panel, registers a resize handler, and returns an unmount function. run() immediately unmounts every panel. The only difference between the two modes is whether unmounting also removes the handler.
Run the broken mode:
memoryDemo.run({ cleanup: false, times: 20 });
document.querySelectorAll(".memory-demo-panel").length; // 0
window.dispatchEvent(new Event("resize"));
memoryDemo.resizeCalls; // 20
No panels remain in the document, yet all 20 old handlers respond to a resize event. This is a deterministic symptom of missing cleanup, not a heap-size measurement.
The capture matters. Each onResize function refers to the panel created by its own call to mount(). Because the window still owns that listener, the callback remains reachable, and so does the detached panel. The global memoryDemo object contains the test functions and counter; it does not store the panels.
Find the retaining reference in Chrome DevTools
You can verify the same problem with heap snapshots rather than assuming every detached node is a leak. Chrome's Memory panel guide and heap snapshot documentation describe the relevant controls.
- Reload the blank page and install the snippet again. Open DevTools > Memory and select Heap snapshot. Take Snapshot 1 before calling
memoryDemo.run(). - Run
memoryDemo.run({ cleanup: false, times: 20 }). Confirm the document contains no.memory-demo-panelelements. - Take Snapshot 2. In Comparison, compare it with Snapshot 1. Inspect additional DOM nodes, especially detached elements. The exact names and counts can vary with the browser and other allocations.
- Select a suspect detached
sectionand follow its Retainers chain. Look for the path through a resize event listener and its closure to the panel. That retaining path, not the presence of a node in one snapshot, explains why it remains alive. - If the object isn't easy to find, repeat the mount/unmount cycle more times on a fresh page. Avoid evaluating a DOM node into the Console or leaving a debugger paused while profiling: debugging tools can themselves retain references.
A snapshot captures reachable objects at that moment. It does not tell you whether every retained object is unintended. A cache, active editor tab, or a still-mounted component can legitimately survive a collection. The task is to compare what is retained with what the UI's lifecycle says should have been released.
Shallow size is not the same as retained size
In the snapshot, shallow size is memory attributed to an object itself. Retained size estimates memory that could become reclaimable if that object and the objects it exclusively retains were no longer reachable through it. A small listener or collection can keep a much larger object graph alive.
Do not treat a single retained-size number as the exact amount of process RAM you will recover. Browser memory includes DOM and other native allocations as well as the JavaScript heap, and memory managers may reuse freed memory instead of immediately returning it to the operating system.
Test the fix without contaminating the comparison
The snippet already contains the corrected behavior: removeEventListener() receives the same function originally registered on window. That identity is important. A new arrow function with identical code would not remove the old handler. MDN documents the listener matching rules.
Reload the page, install the snippet again, and run only the fixed path:
memoryDemo.run({ cleanup: true, times: 20 });
document.querySelectorAll(".memory-demo-panel").length; // 0
window.dispatchEvent(new Event("resize"));
memoryDemo.resizeCalls; // 0
The fresh reload matters. Running the fixed path after the broken path does not remove the 20 listeners already leaked by the earlier run.
Take another baseline and follow-up heap snapshot on this fresh page. The expectation is that the unmounted panels no longer have a listener retaining path. Object counts may still fluctuate, so examine the paths and repeat the experiment rather than demanding exactly zero change in total heap usage.
In an isolated headless Chromium 144 check of this exact reproduction, a heap snapshot taken after explicit garbage collection contained 20 nodes named <section class="memory-demo-panel"> with the broken cleanup and 0 with the fixed cleanup. Both modes left 0 matching elements in the document. These are fixture observations, not a measured memory saving or evidence about a production application.
For a real component, return cleanup from the same code that registers the listener. When multiple listeners share one lifecycle, an AbortController can make teardown less error-prone:
function mountResponsivePanel(panel) {
const controller = new AbortController();
window.addEventListener(
"resize",
() => {
panel.dataset.width = String(window.innerWidth);
},
{ signal: controller.signal }
);
return () => controller.abort();
}
Calling the returned function removes the listener. This alternative is supported by the addEventListener() signal option. It does not remove the panel from the document, so the caller remains responsible for its DOM lifecycle. A { once: true } listener is not an equivalent fix when the event might never fire.
Is growing memory actually a leak?
Memory growth during an action is not sufficient evidence. An application can allocate temporary objects rapidly, retain a bounded cache, or warm up engine internals without leaking indefinitely.
| What you observe | What to investigate |
|---|---|
| Heap rises during work, then falls after collection | Temporary allocation or GC activity; check if responsiveness suffers |
| Repeating mount/unmount leaves more detached nodes reachable | Listener, closure, timer, or application collection holding obsolete UI |
| Heap settles at a higher but stable baseline | Intentional cache, retained application state, or one-time initialization |
| Process memory grows but JS heap looks stable | DOM, image, canvas, native memory, or other non-heap allocations |
A useful test repeats the same user action and its reverse several times, ideally after a fresh load. Compare snapshots and identify a plausible retaining path. Also distinguish two performance questions: "Are objects staying alive too long?" and "Are we allocating so much temporary work that garbage collection becomes noticeable?" They require different fixes.
If you're trying to isolate an interaction rather than a memory issue, debugging browser behavior with DevTools covers complementary inspection techniques.
Fix ownership before reaching for weak references
The reproduction's correct fix is ordinary cleanup. The same ownership question applies elsewhere:
- A recurring timer can keep its callback and captured state reachable until
clearInterval()is called. - A
Mapused as an unbounded cache keeps its entries strongly reachable while the map is reachable. Add an explicit size, expiry, or lifecycle policy when appropriate. - A component that subscribes to global events should unsubscribe when its lifecycle ends. Merely removing its DOM does not unsubscribe it.
A WeakMap can be useful for metadata keyed by objects whose lifetimes you do not control, but it is not a general replacement for a cache or teardown function. WeakRef and FinalizationRegistry should not be used to schedule required application cleanup: collection timing and finalizer execution are not guaranteed. MDN's weak-reference discussion explains these limits.
One final check before closing a memory bug: restore the original user interaction, repeat it enough times to make retention visible, and confirm both that the stale behavior stops and that the unwanted retaining path disappears. That is stronger evidence than a temporarily lower memory graph.
