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.

One stored reference keeps a whole frame alive The parent page's editorRegistry array holds the contentWindow of an iframe that has already been removed from the DOM. That Window object retains the frame's Document with its DOM tree, the frame's global scope with its loaded scripts and state, and its style sheets. Together they can be tens of megabytes. A second path shows a listener registered by the frame on the parent window. Parent page editorRegistry[] holds frame.contentWindow window listeners frame's onResize closure Removed iframe — should be garbage Window (frame) HTMLDocument + whole DOM tree global scope scripts, state, caches style sheets and resources

Step-by-Step Fix

  1. 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 Window and by HTMLDocument. Verification: you see about ten more Window objects than there are live frames.
  2. Pick one extra Window and read its retainers. Select a Window that does not belong to a live frame. Verification: the Retainers pane leads to a parent-side variable — an array, a map, a stored contentWindow, or the parent window’s listener list.
  3. 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 for window.parent, window.top and opener. Verification: you have a list of places where one side stores the other’s objects.
  4. 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 a pagehide handler. Verification: no parent structure holds a frame object after close.
  5. Prefer message passing over direct access. Replace direct calls into the frame with postMessage and a MessageChannel, closing the channel’s ports on teardown. Verification: the parent no longer needs to keep frame objects at all.
  6. Re-run the ten cycles. Repeat step 1. Verification: the number of Window and HTMLDocument objects equals the number of live frames, and Task Manager memory returns close to its starting value.
Ten open/close cycles, before and after Before the fix, ten cycles leave 11 documents alive and the JS heap at 184 megabytes. After releasing stored contentWindow references and parent listeners, one document remains, the main page, and the JS heap is 52 megabytes. After 10 open/close cycles of an embedded editor Live documents 11 1 (main page) JS heap 184 MB 52 MB before after

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.

What a fixed frame leak looks like After ten open and close cycles, the snapshot holds exactly one Window and HTMLDocument pair per live frame plus the main page, the Task Manager footprint returns within a few megabytes of its start, and no instances of classes defined only inside the frame remain. After ten open/close cycles Window/document pairs Exactly one per live frame plus the main page in the snapshot. Task Manager footprint Back within a few MB of the starting value. Frame-only classes No instances of classes defined in the frame’s scripts remain.

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.