Detached window memory leaks

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 ot…

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.

See Solution: communicate over postMessage to
learn how to fix this particular memory leak.

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.

A screenshot of a heap snapshot in Chrome DevTools showing the references that retain
       a large object.
A heap snapshot showing the references that retain a large object.

To record a heap snapshot, head over to the Memory tab in Chrome DevTools and select Heap
Snapshot
in the list of available profiling types. Once the recording has finished, the
Summary view shows current objects in-memory, grouped by constructor.

Demonstration of taking a heap snapshot in Chrome DevTools.

Try it!
Open this step-by-step walk through in a new window.

Analyzing heap dumps can be a daunting task, and it can be quite difficult to find the right
information as part of debugging. To help with this, Chromium engineers
yossik@ and peledni@ developed a
standalone Heap Cleaner tool that can help highlight a
specific node like a detached window. Running Heap Cleaner on a trace removes other unnecessary
information from the retention graph, which makes the trace cleaner and much easier to read.

Measure memory programmatically

Heap snapshots provide a high level of detail and are excellent for figuring out where leaks occur,
but taking a heap snapshot is a manual process. Another way to check for memory leaks is to obtain
the currently used JavaScript heap size from the performance.memory API:

A screenshot of a section of the Chrome DevTools user interface.
Checking the used JS heap size in DevTools as a popup is created, closed and unreferenced.

The performance.memory API only provides information about the JavaScript heap size, which means
it doesn’t include memory used by the popup’s document and resources. To get the full picture, we’d
need to use the new performance.measureUserAgentSpecificMemory() API currently being
trialled in Chrome.

Solutions for avoiding detached window leaks

The two most common cases where detached windows cause memory leaks are when the parent document
retains references to a closed popup or removed iframe, and when unexpected navigation of a window
or iframe results in event handlers never being unregistered.

Example: Closing a popup

The unset references,
monitor and dispose, and WeakRef solutions are
all based off of this example.

In the following example, two buttons are used to open and close a popup window. In order for the
Close Popup button to work, a reference to the opened popup window 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>

At first glance, it seems like the above code avoids common pitfalls: no references to the popup’s
document are retained, and no event handlers are registered on the popup window. However, once the
Open Popup button is clicked the popup variable now references the opened window, and that
variable is accessible from the scope of the Close Popup button click handler. Unless popup is
reassigned or the click handler removed, that handler’s enclosed reference to popup means it can’t
be garbage-collected.

Solution: Unset references

Variables that reference another window or its document cause it to be retained in memory. Since
objects in JavaScript are always references, assigning a new value to variables removes their
reference to the original object. To "unset" references to an object, we can reassign those
variables to the value null.

Applying this to the previous popup example, we can modify the close button
handler to make it "unset" its reference to the popup window:

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

This helps, but reveals a further problem specific to windows created using open(): what if the
user closes the window instead of clicking our custom close button? Further still, what if the user
starts browsing to other websites in the window we opened? While it originally seemed sufficient to
unset the popup reference when clicking our close button, there is still a memory leak when users
don’t use that particular button to close the window. Solving this requires detecting these cases in
order to unset lingering references when they occur.

Solution: Monitor and dispose

In many situations, the JavaScript responsible for opening windows or creating frames does not have
exclusive control over their lifecycle. Popups can be closed by the user, or navigation to a new
document can cause the document previously contained by a window or frame to become detached. In
both cases, the browser fires an pagehide event to signal that the document is being unloaded.

Caution:
There’s another event calledunload which is similar topagehide but is considered harmful and
should be avoided as much as possible. See
Legacy lifecycle APIs to avoid: the unload event
for details.

The pagehide event can be used to detect closed windows and navigation away from the current
document. However, there is one important caveat: all newly-created windows and iframes contain an
empty document, then asynchronously navigate to the given URL if provided. As a result, an initial
pagehide event is fired shortly after creating the window or frame, just before the target
document has loaded. Since our reference cleanup code needs to run when the target document is
unloaded, we need to ignore this first pagehide event. There are a number of techniques for doing
so, the simplest of which is to ignore pagehide events originating from the initial document’s
about:blank URL. Here’s how it would look in our popup example:

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;
});
};

It’s important to note that this technique only works for windows and frames that have the same
effective origin as the parent page where our code is running. When loading content from a different
origin, both location.host and the pagehide event are unavailable for security reasons. While
it’s generally best to avoid keeping references to other origins, in the rare cases where this is
required it is possible to monitor the window.closed or frame.isConnected properties. When these
properties change to indicate a closed window or removed iframe, it’s a good idea to unset any
references to it.

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

Solution: Use WeakRef

WeakRef is a new feature of the JavaScript language,
available in desktop Firefox since version 79
and
Chromium-based browsers since version 84.
Since it’s not yet widely-supported, this solution is better suited to tracking down and debugging
issues rather than fixing them for production.

JavaScript recently gained support for a new way to reference objects that allows garbage collection
to take place, called WeakRef. A WeakRef created for an object is not a direct
reference, but rather a separate object that provides a special .deref() method that returns a
reference to the object as long as it has not been garbage-collected. With WeakRef, it is
possible to access the current value of a window or document while still allowing it to be garbage
collected. Instead of retaining a reference to the window that must be manually unset in response
to events like pagehide or properties like window.closed, access to the window is obtained
as-needed. When the window is closed, it can be garbage collected, causing the .deref() method
to begin returning 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 interesting detail to consider when using WeakRef to access windows or documents is that the
reference generally remains available for a short period of time after the window is closed or
iframe removed. This is because WeakRef continues returning a value until its associated object
has been garbage-collected, which happens asynchronously in JavaScript and generally during idle
time. Thankfully, when checking for detached windows in the Chrome DevTools Memory panel, taking
a heap snapshot actually triggers garbage collection and disposes the weakly-referenced window. It’s
also possible to check that an object referenced via WeakRef has been disposed from JavaScript,
either by detecting when deref() returns undefined or using the new
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());

Solution: Communicate over postMessage

Detecting when windows are closed or navigation unloads a document gives us a way to remove
handlers and unset references so that detached windows can be garbage collected. However, these
changes are specific fixes for what can sometimes be a more fundamental concern: direct coupling
between pages.

A more holistic alternative approach is available that avoids stale references between windows and
documents: establishing separation by limiting cross-document communication to
postMessage(). Thinking back to our original presenter notes example, functions
like nextSlide() updated the notes window directly by referencing it and manipulating its
content. Instead, the primary page could pass the necessary information to the notes window
asynchronously and indirectly 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;

While this still requires the windows to reference each other, neither retains a reference to the
current document from another window. A message-passing approach also encourages designs where
window references are held in a single place, meaning only a single reference needs to be unset when
windows are closed or navigate away. In the above example, only showNotes() retains a reference to
the notes window, and it uses the pagehide event to ensure that reference is cleaned up.

Solution: Avoid references using noopener

In cases where a popup window is opened that your page doesn’t need to communicate with or control,
you may be able to avoid ever obtaining a reference to the window. This is particularly useful
when creating windows or iframes that will load content from another site. For these cases,
window.open() accepts a "noopener" option that works just like the
rel="noopener" attribute for HTML links:

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

The "noopener" option causes window.open() to return null, making it impossible to
accidentally store a reference to the popup. It also prevents the popup window from getting a
reference to its parent window, since the window.opener property will be null.

Feedback

Hopefully some of the suggestions in this article help with finding and fixing memory leaks. If you
have another technique for debugging detached windows or this article helped uncover leaks in your
app, I’d love to know! You can find me on Twitter @_developit.


Print Share Comment Cite Upload Translate
APA
Jason Miller | Sciencx (2024-03-28T12:21:50+00:00) » Detached window memory leaks. Retrieved from https://www.scien.cx/2020/09/29/detached-window-memory-leaks/.
MLA
" » Detached window memory leaks." Jason Miller | Sciencx - Tuesday September 29, 2020, https://www.scien.cx/2020/09/29/detached-window-memory-leaks/
HARVARD
Jason Miller | Sciencx Tuesday September 29, 2020 » Detached window memory leaks., viewed 2024-03-28T12:21:50+00:00,<https://www.scien.cx/2020/09/29/detached-window-memory-leaks/>
VANCOUVER
Jason Miller | Sciencx - » Detached window memory leaks. [Internet]. [Accessed 2024-03-28T12:21:50+00:00]. Available from: https://www.scien.cx/2020/09/29/detached-window-memory-leaks/
CHICAGO
" » Detached window memory leaks." Jason Miller | Sciencx - Accessed 2024-03-28T12:21:50+00:00. https://www.scien.cx/2020/09/29/detached-window-memory-leaks/
IEEE
" » Detached window memory leaks." Jason Miller | Sciencx [Online]. Available: https://www.scien.cx/2020/09/29/detached-window-memory-leaks/. [Accessed: 2024-03-28T12:21:50+00:00]
rf:citation
» Detached window memory leaks | Jason Miller | Sciencx | https://www.scien.cx/2020/09/29/detached-window-memory-leaks/ | 2024-03-28T12:21:50+00:00
https://github.com/addpipe/simple-recorderjs-demo