Detached window memory leaks

Find and fix tricky memory leaks caused by detached windows.

Bartek Nowierski
Bartek Nowierski

What's a memory leak in JavaScript?

A memory leak is an unintentional increase in the amount of memory used by an application over time. In JavaScript, memory leaks happen when objects are no longer needed, but are still referenced by functions or other objects. These references prevent the unneeded objects from being reclaimed by the garbage collector.

The job of the garbage collector is to identify and reclaim objects that are no longer reachable from the application. This works even when objects reference themselves, or cyclically reference each other–once there are no remaining references through which an application could access a group of objects, it can be garbage collected.

let A = {};
console.log(A); // local variable reference

let B = {A}; // B.A is a second reference to A

A = null; // unset local variable reference

console.log(B.A); // A can still be referenced by B

B.A = null; // unset B's reference to A

// No references to A are left. It can be garbage collected.

A particularly tricky class of memory leak occurs when an application references objects that have their own lifecycle, like DOM elements or popup windows. It's possible for these types of objects to become unused without the application knowing, which means application code may have the only remaining references to an object that could otherwise be garbage collected.

What's a detached window?

In the following example, a slideshow viewer application includes buttons to open and close a presenter notes popup. Imagine a user clicks Show Notes, then closes the popup window directly instead of clicking the Hide Notes button–the notesWindow variable still holds a reference to the popup that could be accessed, even though the popup is no longer in use.

<button id="show">Show Notes</button>
<button id="hide">Hide Notes</button>
<script type="module">
  let notesWindow;
  document.getElementById('show').onclick = () => {
    notesWindow = window.open('/presenter-notes.html');
  };
  document.getElementById('hide').onclick = () => {
    if (notesWindow) notesWindow.close();
  };
</script>

This is an example of a detached window. The popup window was closed, but our code has a reference to it that prevents the browser from being able to destroy it and reclaim that memory.

When a page calls window.open() to create a new browser window or tab, a Window object is returned that represents the window or tab. Even after such a window has been closed or the user has navigated it away, the Window object returned from window.open() can still be used to access information about it. This is one type of detached window: because JavaScript code can still potentially access properties on the closed Window object, it must be kept in memory. If the window included a lot of JavaScript objects or iframes, that memory can't be reclaimed until there are no remaining JavaScript references to the window's properties.

Using Chrome DevTools to demonstrate how it's possible to retain a document after a window has been closed.

The same issue can also occur when using <iframe> elements. Iframes behave like nested windows that contain documents, and their contentWindow property provides access to the contained Window object, much like the value returned by window.open(). JavaScript code can keep a reference to an iframe's contentWindow or contentDocument even if the iframe is removed from the DOM or its URL changes, which prevents the document from being garbage collected since its properties can still be accessed.

Demonstration of how an event handler can retain an iframe's document, even after navigating the iframe to a different URL.

In cases where a reference to the document within a window or iframe is retained from JavaScript, that document will be kept in-memory even if the containing window or iframe navigates to a new URL. This can be particularly troublesome when the JavaScript holding that reference doesn't detect that the window/frame has navigated to a new URL, since it doesn't know when it becomes the last reference keeping a document in memory.

How detached windows cause memory leaks

When working with windows and iframes on the same domain as the primary page, it's common to listen for events or access properties across document boundaries. For example, let's revisit a variation on the presentation viewer example from the beginning of this guide. The viewer opens a second window for displaying speaker notes. The speaker notes window listens forclick events as its cue to move to the next slide. If the user closes this notes window, the JavaScript running in the original parent window still has full access to the speaker notes document:

<button id="notes">Show Presenter Notes</button>
<script type="module">
  let notesWindow;
  function showNotes() {
    notesWindow = window.open('/presenter-notes.html');
    notesWindow.document.addEventListener('click', nextSlide);
  }
  document.getElementById('notes').onclick = showNotes;

  let slide = 1;
  function nextSlide() {
    slide += 1;
    notesWindow.document.title = `Slide  ${slide}`;
  }
  document.body.onclick = nextSlide;
</script>

Imagine we close the browser window created by showNotes() above. There's no event handler listening to detect that the window has been closed, so nothing is informing our code that it should clean up any references to the document. The nextSlide() function is still "live" because it is bound as a click handler in our main page, and the fact that nextSlide contains a reference to notesWindow means the window is still referenced and can't be garbage collected.

Illustration of how references to a window prevent it from being garbage collected once closed.

There are a number of other scenarios where references are accidentally retained that prevent detached windows from being eligible for garbage collection:

  • Event handlers can be registered on an iframe's initial document prior to the frame navigating to its intended URL, resulting in accidental references to the document and the iframe persisting after other references have been cleaned up.

  • A memory-heavy document loaded in a window or iframe can be accidentally kept in-memory long after navigating to a new URL. This is often caused by the parent page retaining references to the document in order to allow for listener removal.

  • When passing a JavaScript object to another window or iframe, the Object's prototype chain includes references to the environment it was created in, including the window that created it. This means it's just as important to avoid holding references to objects from other windows as it is to avoid holding references to the windows themselves.

    index.html:

    <script>
      let currentFiles;
      function load(files) {
        // this retains the popup:
        currentFiles = files;
      }
      window.open('upload.html');
    </script>
    

    upload.html:

    <input type="file" id="file" />
    <script>
      file.onchange = () => {
        parent.load(file.files);
      };
    </script>
    

Detecting memory leaks caused by detached windows

Tracking down memory leaks can be tricky. It is often difficult to construct isolated reproductions of these issues, particularly when multiple documents or windows are involved. To make things more complicated, inspecting potential leaked references can end up creating additional references that prevent the inspected objects from being garbage collected. To that end, it's useful to start with tools that specifically avoid introducing this possibility.

A great place to start debugging memory problems is to take a heap snapshot. This provides a point-in-time view into the memory currently used by an application - all the objects that have been created but not yet garbage-collected. Heap snapshots contain useful information about objects, including their size and a list of the variables and closures that reference them.