Hunting memory leaks - EGroupware/egroupware GitHub Wiki

If something is referenced in javascript and it becomes detached from our our structures (DOM or etemplate) it can remain in memory after the popup or tab closes. The javascript engine will garbage collect automatically, but will not clean up anything that is still referenced. This consumes memory over time. This also applies to Objects and Arrays - if it is still referenced, it can't be garbage collected.

Avoiding Memory Leaks: Clean up after yourself

If you create an object, it has to be removed. If you reference something, the reference has to be cleared.

DOM Nodes

  • Old widget DOM nodes are usually removed automatically
  • WebComponent widgets are (should be) self contained

Keeping an internal reference to a DOM node will often result in a memory leak because the reference prevents the node from being garbage collected.

Bad: This creates a reference to a DOM node.

Even if the node is removed from the DOM, the reference remains and the node is retained in memory as a detached node.

loadingFinished() {
	this.specialNode = this.node.querySelector("#special");
}

Better: The node is released

When the container is destroyed, the node is removed and the reference is cleared.

loadingFinished() {
	this.specialNode = this.node.querySelector("#special");
}
destroy() {
	this.specialNode.remove();
	this.specialNode = null;
}

Current best: No reference

By using an accessor instead of a reference, we don't need loadingFinished() to setup or destroy() to clean up.

get specialNode() { return this.node?.querySelector("#special"); }

Event handlers

Bad: This creates a reference to a DOM node.

Even if the node is removed, the reference remains and the node is retained in memory as a detached node. jQuery caches this as well.

loadingFinished() {
	jQuery(this.specialNode).on("click", function(e) { ... });
}

Better: The node is released

When the container is destroyed, the listener is removed and the reference is cleared.

loadingFinished() {
	this.specialNode.addEventListener("click", this.handleClick);
}
destroy() {
	this.specialNode.removeEventListener("click", this.handleClick);
}

Current best: WebComponents

WebComponents automatically clean their internal listeners and provide lifecycle callbacks.

connectedCallback() {
	// Bind external listener
	document.addEventListener("load", this.handleLoad);
}
disconnectedCallback() {
	// Free the external listener 
	// - automatically called when component is removed from the DOM
	// - no need to call disconnectedCallback() ourselves
	document.removeEventListener("load", this.handleLoad);
}
render() {
	...
	// Cleaned up automatically
	<div id="specialNode" @click=${this.handleClick} />
	...
}

External listeners still need to be cleaned as above, so if you're using a webComponent instead of writing one:

et2_init(et2) {
	et2.getWidgetById("myWidget")?.addEventListener("click", this.handleMyWidgetClick);
}
destroy() {
	this.et2?.getWidgetById("myWidget")?.removeEventListener("click", this.handleMyWidgetClick);
	super.destroy();
}

This is only an example In this case it's simpler to set the listener in the .xet file:

<et2-hbox id="myWidget" onclick="app.myApp.handleMyWidgetClick"/> This listener will be bound and cleaned up by Etemplate.

Finding Memory Leaks

The tool to find memory leaks is the "Memory" tab in Chrome's DevTools. Use 'Heap snapshot'. All the tools have their place and this is just an overview of strategy for EGroupware. See the DevTools docs for more information.

Screenshot from 2025-03-19 10-36-00

The general strategy is "3 snapshots":

  1. First snapshot is baseline, before you do a thing.
  2. Do a thing and get back to baseline state. (Open & close a popup, tab or dialog, add then remove, etc.)
  3. Second snapshot.
  4. Do it again, and back to baseline state.
  5. Third snapshot.

Using the Summary view, look at objects created between second and third snapshots. This avoids including any of EGroupware's framework or first-time setup things. We're looking for leaks, not general memory usage.

What are you looking for?

In general the biggest memory users have the largest "Retained Size", so that's what you would look at first. However in EGroupware, usually the culprits of memory leaks are detached DOM nodes that got retained. Use the 'Filter by class' to search for "Detached" to show only detached DOM Nodes. Once there are no detached nodes, then it's time to look into the others.

Screenshots are taken from a heap snapshot provided by Alexandros Sigalas, and are used as an example for the process.

Screenshot from 2025-03-19 11-14-57

In the above screenshot we can see that the top 3 lines account for 475 MB of leaked memory, so that's where we will want to look first. Array and Function also need investigation but we want to get rid of the detached nodes first because often freeing detached nodes will free up any arrays or functions they are holding onto.

Starting with the first detached <sl-option>, we can look to see what's holding onto it.

Screenshot from 2025-03-19 11-21-29

We can see that each option only takes 8 kB, but there's a lot of them. Chrome helpfully expands and highlights the chain of ownership holding the element in memory. It seems that the problem is that the ET2Select cache is holding onto every <sl-option> element created instead of only caching the data to create them when needed.

Work the structure

It's tempting to go dig into the cache to find out what's going on there, but some knowledge of the structure of EGroupware or your application is needed. <sl-option> live inside <et2-select> widgets. Looking at the Retainers list, we can see that after some "Detached InternalNode" lines there's a detached <form>, which is inside of a detached HTMLDocument. The <form>'s target of "egw-iframe_autocomplete_helper" (and other attributes cut off in the screenshot) indicate that this is actually the parent of the entire etemplate - it's not just the <sl-option> but the entire template has been detached and left in memory, and the parent document too.

Screenshot from 2025-03-19 11-49-59

Looking at the next group of detached elements <sl-icon> shows this too. This detached icon is inside a detached <sl-option>... inside a detached <form>. Checking through some of the other detached nodes lower down in the list shows the same issue - the whole form is retained, as well as the parent document.

Since an etemplate retains references to all its children this looks more like the etemplate is not being properly cleaned up than a bug in the select cache. Rather than focusing on the <sl-icon> or <sl-option> nodes, a better start is to find out why the document or <form> are retained.

Fix the problem

Finding where the document or form references are retained means looking through the retainers list.

  • There's a lot of noise. Simpler test cases help reduce this.
  • There may be multiple references that have to be cleared, so the process may have to be repeated.

This particular example was an edit popup. When the popup was closed, egw framework retained a reference to the window causing the detached HTMLDocument. In addition, the <form> element was left on the page and the eTemplate had a reference to the it.

Changing the framework's popup garbage collection freed the window.

Clearing the eTemplate & removing the <form> element removed them, but also removed almost all the other detached nodes.

A simpler example

While the above is a realistic example, here's a simpler example that's a little easier to follow to the source. These <et2-button-icon> nodes were still left after clearing up the document and form.

Screenshot from 2025-03-19 13-35-06

Here we have an Array held by something named "tooltipped". It's held in the context of a method tooltipBind(), held by egw(). Searching through EGroupware's code, we find where it is declared:

https://github.com/EGroupware/egroupware/blob/cbfc12eabc46bc15f11d4ce1d47823b745fbf6cc/api/js/jsapi/egw_tooltip.js#L28

And tooltipBind() where it is used:

https://github.com/EGroupware/egroupware/blob/cbfc12eabc46bc15f11d4ce1d47823b745fbf6cc/api/js/jsapi/egw_tooltip.js#L193-L199

Now that we know where the reference is we need to figure out how to either never create it, or make sure it is removed. In this case, since the reference was created in tooltipBind(), the solution is to remove it in tooltipUnbind() using splice() to remove it from the array:

https://github.com/EGroupware/egroupware/blob/cbfc12eabc46bc15f11d4ce1d47823b745fbf6cc/api/js/jsapi/egw_tooltip.js#L255-L265

This is the last reference holding on to the <et2-button> so once we remove it, the button is no longer held after the etemplate is cleared. This memory leak is plugged.
This can be confirmed with another snapshot using this change which will be missing the <et2-button>.

⚠️ **GitHub.com Fallback** ⚠️