When JavaScript Holds On After a Window Is Gone

A memory leak in JavaScript is an unintended rise in memory use over time. It happens when objects are no longer needed but are still referenced by application code, which stops the garbage collector from reclaiming them. The collector can handle objects that reference themselves or each other in cycles—once nothing reachable from the app points to a group, it can be collected. The trouble starts when an app references objects with their own lifecycle, like DOM elements or popup windows. These can become unused without the app realizing it, leaving app code as the only thing keeping them alive.

The Problem: Detached Windows

Imagine a slideshow app that opens a presenter notes popup. If a user closes the popup directly instead of pressing a Hide Notes button, the code's notesWindow variable might still reference that closed window. Even though the popup is gone from the screen, the browser can't fully destroy it because the reference remains. This is a detached window.

// A snippet of problematic code from a slideshow viewer
let notesWindow;

function showNotes() {
  notesWindow = window.open('/notes.html', 'presenter-notes');
}

function hideNotes() {
  if (notesWindow) {
    notesWindow.close();
    notesWindow = null;
  }
}

When window.open() creates a new window or tab, it returns a Window object. Even after that window closes or navigates elsewhere, that object can still be used to read properties from the now-defunct window. As long as JavaScript can technically reach those properties, the whole window—including any heavy JavaScript objects or iframes it contained—must stay in memory. Only when every reference is dropped can the browser reclaim that memory.

Iframes Have the Same Issue

The same leak pattern shows up with <iframe> elements, which behave like nested windows. Their contentWindow property exposes the inner Window, and contentDocument exposes the inner document. Code can keep a reference to either of those after the iframe is removed from the DOM or changes URL. With a live reference, the document can't be garbage collected because its properties might still be accessed—especially if the holding code doesn't detect the navigation that made the reference stale.

How Detached Windows Leak Memory

In same-origin scenarios, it's natural to wire event listeners or read properties across window and iframe boundaries. Consider the presentation viewer again: the notes window listens for clicks to trigger slide advances. Those listeners live in the parent page's JavaScript, and they close over the notes window's document. When the user closes the popup, nothing tells the parent to clean up. The click handler is still active, and because it references notesWindow, the whole closed window stays in memory.

Other common retention paths include:

  • Registering event handlers on an iframe's initial document before it navigates to its real URL. Those handlers can keep the document and iframe alive after everything else is cleaned up.
  • Holding onto a document reference just to remove a listener later. If the window or iframe navigates in between, the heavy old document is kept alive unnecessarily.
  • Passing objects across window boundaries. An object's prototype chain references the environment where it was created—including the originating window. A reference to a cross-window object is effectively a reference to that window.

Detecting Leaks From Detached Windows

Chasing these leaks is hard because they rarely reproduce in isolation, and inspecting a suspected reference can itself create a new one. The safest starting point is a heap snapshot, which gives a point-in-time view of every object that hasn't been collected. Each snapshot entry includes object size and the variables and closures that reference it.

To take one, open Chrome DevTools, go to the Memory tab, and pick Heap Snapshot from the profiling types. After it finishes, the Summary view lists in-memory objects grouped by constructor. That data can be overwhelming, so Chromium engineers built a standalone Heap Cleaner tool. It trims non-essential information from the retention graph, making a specific leaked node like a detached window much easier to spot.

Measuring Memory in Code

Heap snapshots are detailed but manual, so they aren't ideal for tracking leaks over time. The performance.memory API exposes the current JavaScript heap size, which is useful for programmatic checks. It covers only the heap, though—not the memory used by a popup's document or its resources. For the complete picture, Chrome is trialing the newer performance.measureUserAgentSpecificMemory() API, which accounts for total page memory usage.

Fixing detached window leaks

Detached window leaks usually come from two sources: the parent document keeping references to a closed popup or removed iframe, and event handlers that are never unregistered after a window or iframe navigates unexpectedly.

The popup problem

Consider a page with buttons to open and close a popup. The Close Popup button needs a reference to the popup, so it is stored in a variable:

<button id="open">Open Popup</button>
<button id="close">Close Popup</button>
<script>
  let popup;
  open.onclick = () => {
    popup = window.open('/login.html');
  };
  close.onclick = () => {
    popup.close();
  };
</script>

This looks safe at first: no references to the popup's document are kept, and the popup has no event handlers registered on it. But once Open Popup is clicked, the popup variable holds the window object, and that variable is in scope for the close handler. Unless popup is reassigned or the handler removed, the handler's closure keeps the popup alive and it cannot be garbage-collected.

Unset stale references

Because JavaScript objects are references, reassigning a variable removes its hold on the original object. Setting such variables to null releases the reference. Applying that to the popup example, the close handler becomes:

let popup;
open.onclick = () => {
  popup = window.open('/login.html');
};
close.onclick = () => {
  popup.close();
  popup = null;
};

That only fixes the happy path. What if the user closes the window directly, or navigates it to another site? Clicking the custom close button is no longer the only way the popup can disappear, so the reference cleanup must run in those cases too.

Monitor disposal with pagehide

Code that opens windows or creates frames often does not control their full lifecycle. Users can close popups, and navigation to a new document can detach the previous one. In both situations the browser fires the pagehide event, signaling that the document is being unloaded.

There is a caveat with newly created windows and iframes: they start with an empty document, then asynchronously navigate to the target URL if one was provided. That means an initial pagehide fires just before the target document loads. The cleanup should only run when the target document is unloaded, so this first event must be ignored. The simplest way is to skip pagehide events from the initial about:blank document:

let popup;
open.onclick = () => {
  popup = window.open('/login.html');

  // listen for the popup being closed/exited:
  popup.addEventListener('pagehide', () => {
    // ignore initial event fired on "about:blank":
    if (!popup.location.host) return;

    // remove our reference to the popup window:
    popup = null;
  });
};

This technique is limited to windows and frames with the same effective origin as the parent page. Cross-origin content hides both location.host and the pagehide event for security reasons. For the rare cases that need references to other origins, poll window.closed or frame.isConnected and unset references when they indicate the window closed or the iframe was removed:

let popup = window.open('https://example.com');
let timer = setInterval(() => {
  if (popup.closed) {
    popup = null;
    clearInterval(timer);
  }
}, 1000);

Use WeakRef instead

JavaScript's WeakRef offers a different way to hold objects without preventing garbage collection. A WeakRef wraps the target object and exposes a .deref() method that returns the object if it is still alive. This means a window or document reference can be obtained as-needed rather than held permanently, and the window becomes collectible immediately after it closes. Once collected, deref() returns undefined:

<button id="open">Open Popup</button>
<button id="close">Close Popup</button>
<script>
  let popup;
  open.onclick = () => {
    popup = new WeakRef(window.open('/login.html'));
  };
  close.onclick = () => {
    const win = popup.deref();
    if (win) win.close();
  };
</script>

One subtlety: a WeakRef keeps returning the object for a short time after the window closes or the iframe is removed, because garbage collection runs asynchronously, usually during idle time. In Chrome DevTools, taking a heap snapshot in the Memory panel triggers garbage collection, which disposes weakly-referenced windows. If plain JavaScript checks are needed, watch for deref() returning undefined or use the newer FinalizationRegistry API:

let popup = new WeakRef(window.open('/login.html'));

// Polling deref():
let timer = setInterval(() => {
  if (popup.deref() === undefined) {
    console.log('popup was garbage-collected');
    clearInterval(timer);
  }
}, 20);

// FinalizationRegistry API:
let finalizers = new FinalizationRegistry(() => {
  console.log('popup was garbage-collected');
});
finalizers.register(popup.deref());

Decouple with postMessage

Cleaning up references when windows close addresses the symptom. The underlying problem is often tight coupling between pages. Instead of one window directly manipulating another's document, the pages can exchange data asynchronously through postMessage(). Going back to a presenter-notes example, a function like nextSlide() would no longer need to reach into the notes window's DOM; the primary page would just send the slide information over postMessage():

let updateNotes;
function showNotes() {
  // keep the popup reference in a closure to prevent outside references:
  let win = window.open('/presenter-view.html');
  win.addEventListener('pagehide', () => {
    if (!win || !win.location.host) return; // ignore initial "about:blank"
    win = null;
  });
  // other functions must interact with the popup through this API:
  updateNotes = (data) => {
    if (!win) return;
    win.postMessage(data, location.origin);
  };
  // listen for messages from the notes window:
  addEventListener('message', (event) => {
    if (event.source !== win) return;
    if (event.data[0] === 'nextSlide') nextSlide();
  });
}
let slide = 1;
function nextSlide() {
  slide += 1;
  // if the popup is open, tell it to update without referencing it:
  if (updateNotes) {
    updateNotes(['setSlide', slide]);
  }
}
document.body.onclick = nextSlide;

This does not eliminate window references entirely, but it means no page holds a reference to another page's document. A message-passing design also tends to concentrate window references in one place, so only a single reference needs to be cleared when the window closes or navigates away. In the example above, only showNotes() keeps a reference, and it relies on pagehide for cleanup.

Avoid references with noopener

When a popup is opened purely for the user and the opener never needs to talk to it, the reference can be avoided entirely. This is especially relevant for popups or iframes loading cross-site content. window.open() accepts the "noopener" option, equivalent to the rel="noopener" attribute on links:

window.open('https://example.com/share', null, 'noopener');

With "noopener", window.open() returns null, so there is no way to accidentally store a reference. The popup also cannot reach back, since its window.opener property is null.