Raiding memory leaks in the Pyramid of PrevObject

Raiding memory leaks in the Pyramid of PrevObject

A while ago I found that interaction with a component in our site results in many detached dom elements. At first glance, it wasn't clear from the code what was causing it.
After some research I realized that the detached dom elements are still referenced by prevObject- an internal property of all jQuery objects which is used by the jQuery .addBack() and .end() methods.
If you're not familiar with prevObject or these two rarely used methods, you can use jQuery chaining in a way that essentially creates a memory leak.
In my particular case the effect on the page's memory consumption was minuscule, and I'm generally against micro-optimizations. But since jQuery is used by many people, and since I couldn't find much info about this topic online, I thought I'd share.

This post focuses on what prevObject is used for, why it may cause a leak, and how to fix your code. But for completeness' sake, I'll first cover how I determined that there's a memory leak in the first place, and identified prevObject as the culprit.

How to find the leak and its cause

I took a Timeline recording of an interaction with the page and saw an unexplained rise in the number of dom nodes.
Then I used the "three snapshot technique" to identify the source of the leak. This process is well-documented here, here, and here.
To illustrate the analysis process, we'll use this simplified version of the component in question:


This is a pretty standard tabbed UI. As you click each tab, the old items are removed from the dom, and new items are added to the dom. As you can see in this example, several "sticky" items are persisted. We don't expect the number of dom nodes to increase, but according to the DevTools Memory graph, there's a steady spike in the number of nodes whenever we click one of the tabs:

DevTools Nodes Graph

We now know that something is unexpectedly retaining a reference to dom nodes, and preventing them from being garage-collected. To figure out what that is, we'll use the Profiles panel and the three snapshot technique. We take a Heap Snapshot every time we click on a tab, and then use the filter to see just "Objects allocated between Snapshot 2 and Snapshot 3".
Alas, we catch our first glimpse of the namesake "Pyramid of PrevObject" (see screenshot above).

What is prevObject?

When you select dom elements with jQuery, you get back a jQuery collection. Every time you call a method like filter() or add() on a collection, you get a new collection. Using the end() method, you can "go back in history" and get back the collection as it was before each chaining method was called.
To support this, jQuery needs to keep a reference to each collection. It does this by (recursively) storing the previous collection on a property called prevObject on the collection.

Why it creates a leak

As you can see in the following snippet, even if you filter out elements from a collection and remove them from the dom, as long as you have a reference to the same collection, you implicitly retain them via the prevObject.

// WARNING: Highly contrived code ahead
var items = content.find('.item');
console.log(items.length);
// Output: 25
items = items.filter('.sticky');
console.log(items.length);
// Output: 3
content.empty();
content.append(items);
console.log(items.prevObject.length);
// Output: 25

If you keep reusing the same collection stored in items and applying jQuery methods to it, you'll keep creating new prevObjects.

How to fix it

Once you've identified that you have a prevObject-related leak, it's relatively easy to fix. All you have to do is avoid calling jQuery's chaining methods on the same collection without releasing it.
When you first learn to use jQuery, you're taught to be a good boy/girl and use a variable to store the elements you're working with so you won't have to keep querying the dom for them.
As we can now see, this approach has its disadvantages.
It can cause you to write highly contrived and needlessly complex code as in the example above, and it can trigger the prevObject "leak".

If we rewrite the code above without using the redundant items variable at all, we've eliminated the leak.
Notice that in this approach we're not querying the whole dom, but rather just elements inside the content element.

console.log(content.find('.item').length);
// Output: 25
content.find('.item:not(.sticky)').remove();
console.log(content.find('.item').length);
// Output: 3

In some more complex cases (not in any of the examples shown above), you might still want to use a variable to reference a collection and perform actions on it without creating new prevObjects. You can achieve that by creating a new reference to the existing collection using the $() function.

console.log(items.prevObject.length);
// Output: 25
items = $(items);
console.log(items.prevObject);
// Output: undefined