Detached Iframes and Leaked Window Objects
Every time a user opens and closes an embedded editor, preview or payment frame, memory rises by several megabytes and never comes back — and a heap snapshot shows extra Window and HTMLDocument objects. This guide from Detached DOM Nodes and Memory Retention, in Browser DevTools & Performance Profiling Workflows, explains how a same-origin iframe’s entire document can stay alive after the frame is removed, and how to cut the references that keep it.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
Extra Window / HTMLDocument objects after closing frames |
Parent JS holds iframe.contentWindow or contentDocument |
Null out stored references on close | One whole document (often 5–50 MB) freed per closed frame |
Detached nodes whose ownerDocument is the frame’s document |
Parent cached elements from inside the frame | Stop caching child-frame elements, or clear the cache | Frame document becomes collectable |
message listener on parent retains frame closure |
Handler captured the frame’s window for replies | Remove the listener on close (AbortController) | Handler and captured window released |
| Task Manager shows lingering subframe memory | Cross-origin frame process kept by a parent reference | Remove the element and drop contentWindow proxies |
Subframe memory returns after close |
puppeteer metrics show Documents count rising |
Documents leaked per open/close cycle | Track Documents in automated tests |
Leak detected before release |
Root Cause: A Window Is a Very Large Object Graph
An iframe element in the parent page is small, but the browsing context behind it is not. A same-origin iframe has its own Window, its own Document, its own JavaScript global scope with every script it loaded, its own style sheets, layout tree and resource caches. When you remove the iframe element, the browser tears down the browsing context — but the JavaScript objects of that context can only be collected if nothing in the parent still references them.
Same-origin frames make that easy to get wrong, because the parent can reach straight into the frame: iframe.contentWindow, iframe.contentDocument, frameWindow.someFunction, elements queried from contentDocument and cached for later, callbacks the frame registered on the parent (window.parent.registerEditor(this)). Any one of those references, held in a parent variable, keeps the frame’s Window object and, through it, the frame’s entire global scope and document alive. In a heap snapshot this appears as an additional Window object and a detached HTMLDocument, and the detached DOM node groups include elements whose owner document is the dead frame.
The reverse direction leaks too. Code inside the frame that registers a listener on window.parent or window.top, or pushes a callback into a parent-owned array, gives the parent a strong reference to a closure from the frame’s context. When the frame is removed, the parent’s listener list still holds that closure, the closure holds its context, and the context holds the frame’s globals.
Cross-origin frames are isolated from direct JavaScript access, and with site isolation they usually live in another process. The parent sees only a WindowProxy, so the frame’s heap cannot be retained through ordinary references — but lingering message handlers and cached proxies still keep small objects around, and a frame that is merely hidden rather than removed keeps its whole process alive.
Step-by-Step Fix
- Count documents across open/close cycles. Open and close the frame-hosting feature ten times, then in DevTools → Memory take a heap snapshot and filter the Summary by
Windowand byHTMLDocument. Verification: you see about ten moreWindowobjects than there are live frames. - Pick one extra Window and read its retainers. Select a
Windowthat does not belong to a live frame. Verification: the Retainers pane leads to a parent-side variable — an array, a map, a storedcontentWindow, or the parentwindow’s listener list. - List every cross-frame reference. Search the parent code for
contentWindow,contentDocument,frames[, and any registration API a frame can call (parent.register…). Search the frame code forwindow.parent,window.topandopener. Verification: you have a list of places where one side stores the other’s objects. - Release on close. When closing, remove parent listeners the frame added, delete registry entries, null stored
contentWindow/contentDocument/element references, then remove the iframe element. For frames you control, have the frame clean up in apagehidehandler. Verification: no parent structure holds a frame object after close. - Prefer message passing over direct access. Replace direct calls into the frame with
postMessageand aMessageChannel, closing the channel’s ports on teardown. Verification: the parent no longer needs to keep frame objects at all. - Re-run the ten cycles. Repeat step 1. Verification: the number of
WindowandHTMLDocumentobjects equals the number of live frames, and Task Manager memory returns close to its starting value.
Command and Code Reference
Use case: a parent that keeps frame windows in a registry, and the corrected teardown.
// Leaky: the registry outlives every frame it ever saw
const editorRegistry = [];
function openEditor(container) {
const frame = document.createElement('iframe');
frame.src = '/editor.html';
frame.onload = () => editorRegistry.push(frame.contentWindow); // stored forever
container.append(frame);
return () => frame.remove(); // element gone, window kept
}
// Fixed: talk over a MessageChannel and release everything on close
function openEditorFixed(container) {
const frame = document.createElement('iframe');
frame.src = '/editor.html';
const { port1, port2 } = new MessageChannel();
frame.onload = () => frame.contentWindow.postMessage({ type: 'init' }, location.origin, [port2]);
port1.onmessage = (e) => handleEditorMessage(e.data);
container.append(frame);
return function close() {
port1.onmessage = null; // drop the handler closure
port1.close(); // close the channel; frame side sees it closed
frame.src = 'about:blank';// unload the frame document promptly
frame.remove();
};
}
Use case: frame-side cleanup for listeners registered on the parent. A frame that reaches into its parent must undo that when it unloads.
// Inside editor.html (same origin as the parent)
const controller = new AbortController();
window.parent.addEventListener('resize', () => relayout(), { signal: controller.signal });
// pagehide fires when the frame is removed or navigated away
window.addEventListener('pagehide', () => controller.abort(), { once: true });
Use case: detect document leaks in an automated test. Chrome’s metrics expose a live document count.
// In a Puppeteer test: documents should return to 1 after closing all frames
for (let i = 0; i < 10; i++) {
await page.click('#open-editor');
await page.waitForSelector('iframe.editor');
await page.click('#close-editor');
}
const { Documents } = await page.metrics();
if (Documents > 2) throw new Error(`Leaked documents: ${Documents}`); // main page + slack
Verification and Regression Prevention
A frame leak is fixed when the snapshot contains exactly one Window/HTMLDocument pair per live frame plus the main page, and the Task Manager footprint returns to within a few megabytes of its starting value after ten cycles. Also check the frame’s own global objects: if you still see instances of classes defined only inside the frame’s scripts, some path into its global scope survives.
Keep the automated Documents check in your end-to-end suite for every feature that hosts iframes, and treat any direct contentWindow property access in parent code as needing a documented release path. Features that embed third-party frames — payment, maps, video — benefit from a single wrapper component that owns creation and teardown, so the cleanup logic lives in one place rather than being re-implemented by each feature.
Edge Cases and Gotchas
Hidden is not removed
Hiding a frame with display: none or moving it off-screen keeps its document, scripts and, for cross-origin frames, its process alive. If a frame will not be shown again soon, remove it. If it will, keep exactly one instance and reuse it rather than creating new ones.
Setting src to about:blank
Navigating the frame to about:blank before removing it unloads the old document immediately and runs its pagehide handlers, which makes teardown deterministic. It is a useful belt-and-braces step, but it does not help if the parent still stores the old contentWindow — the WindowProxy then points at the blank document, while stored references to the old document or its elements still retain them.
Popups and window.open
Windows opened with window.open behave like frames: the opener can hold the popup’s window, and the popup can hold window.opener. Null out stored references when the popup closes, and use noopener where the relationship is not needed.
Extensions inject frames too
Some extensions inject iframes into every page. If snapshot counts do not add up, profile in a clean profile as described in profiling memory without extensions skewing results.
Frequently Asked Questions
Does removing an iframe free its memory?
It tears down the browsing context, but the frame’s JavaScript objects and document are collected only if nothing in the parent (or anywhere else) still references them. Stored contentWindow or contentDocument references, cached elements and cross-frame listeners all keep the whole frame alive.
Can a cross-origin iframe leak memory into my page?
Its heap usually lives in a separate process under site isolation, and you cannot hold direct references to its objects. Your page can still leak small objects — message handlers, WindowProxy references — and a frame that is hidden rather than removed keeps its process running. Remove frames you no longer need.
How do I see how many documents are alive?
In a heap snapshot, filter the Summary by HTMLDocument or Window. In automated tests, Puppeteer’s page.metrics() returns a Documents count, and Chrome’s Task Manager lists subframes as separate rows when they run in their own processes.
Related
- Detached DOM Nodes and Memory Retention — the parent topic
- Fixing Detached Nodes Cached in JavaScript Variables — the same pattern for ordinary elements
- Third-Party Widgets Leaving Detached DOM Behind — embedded widgets that use frames and overlays
- Browser DevTools & Performance Profiling Workflows — the section overview