Search code examples
javascriptvue.jsturbolinks

Vue.js memory leak with Turbolinks even with destroy() being called - browser always keeps a referente to vue instance app


I have a Rails apps that uses Turbolinks. If you're not familiar, turbolinks makes so that the document and window object always remains the same (the page is never refreshed), and it intercepts all link clicks via AJAX and replaces the body tag.

When navigating TO and FROM a page that has a Vue app mounted, this is the memory/nodes pattern that we observe (it grows to infinity):

enter image description here

As you can see, on each page change, memory only increases, never being reclaimed.

Our App uses https://github.com/jeffreyguenther/vue-turbolinks, which is basically this code:

beforeMount: function() {
  if (this === this.$root && this.$el) {
    document.addEventListener('turbolinks:visit', function teardown() {
      this.$destroy();
      document.removeEventListener('turbolinks:visit', teardown);
    })

    // cache original element
    this.$cachedHTML = this.$el.outerHTML;

    // register root hook to restore original element on destroy
    this.$once('hook:destroyed', function() {
      if( this.$el.parentNode )
        this.$el.outerHTML = this.$cachedHTML
    });
  }
}

I don't have any reference on window or anywhere else to the Vue app (the instantiation code is just new Vue({ options}); no global variables, modern code using const/let.

There are no document.addEventListener being added from within the app; all event listeners are being added by v-on, which are removed by Vue automatically on destroy.

Trying to debug even further, I patched the code above to add a storage of WeakRef and I set it right before destroying the Vue instance, like this:

document.addEventListener('turbolinks:visit', function teardown() {
  window.weakReferences = window.weakReferences || {};
  window.weakReferences[new Date()] = new window.WeakRef(this);
  this.$destroy();
})

After changing screens for a while, I can confirm the Vue objects are still present in memory (WeakRef.deref() doesnt return undefined), even tough they are all marked as _isDestroyed internally by Vue.

How can one go debugging why the Vue app instances are not being garbage collected and why the browser is keeping a reference to them?

enter image description here


Solution

  • Boy, was I in for a ride. There were 6 memory leaks in my app.

    To discover them, do this:

    1. Open your Chrome devtools and focus the Memory tab (I suggest doing this in a icognito tab, so extensions don't mess with the measurements); refresh your app, and then click the little trash can (which will force a Garbage Collect - GC);

    Start noting the indicator of MB in the "Select Javascript VM Instance" (you'll probably have one line there only). You can ignore the up/down arrow indicators there, focus only on the first number, which is how many MB your tab is using right now.

    enter image description here

    1. Start using your app; in your case, navigating TO and FROM the page that had the Vue app; note if the memory is going always up or if when you navigate away from the Vue app it comes down;

    In your case, navigating TO and FROM the app increased memory usage of 20 MB every time, eventually reaching 200MB after 10+ times or so; clicking the Garbage Collect icon only decreased usage of around 10MB, so it was obvious we head a leak.

    After you have put the page in this leak state and clicked the GC trash icon, select "Heap snapshot" in the radio options. It will collect a snapshot. Click on it.

    In our case, our Vue app was being kept in memory due to the leak. So we focused on the "VueComponent" items in the list. Click the little triangles and watch the lower part of the screen. It will start showing you what pieces of code are retaining those references in memory. Sometimes you'll get lucky and even get some line numbers there, that you can click and it will open in the sources tab.

    We had 6+ leaks in your app; below I'm showing one of them, and you'll see the memory trace is pointing to $notify, which was a lib (vue-notification) we were using:

    enter image description here

    Yes, you can have leaks from other people's code, that's a bummer. Looking at that lib, I could find the culprit was two event handlers defined on the created hook, that were never released; I issued a pull request here.

    1. Rinse and repeat. Of our 6 memory leaks, most of them were easily solved when diagnosed by this method. Some stuff we had:
    • A 'bug' in the mitt library (tiny event emitter); eventEmitter.off() should clear all event emitters, but it didn't; opened an issue here;

    • We subscribed to Vuex events in created (using this.$store.subscribe()) and forgot to unsubscribe in the beforeDestroy(); the subscribe() function returns a function to unsubscribe; save it on the instance itself (like this.unsubscribeVuex = this.$store.subscribe(...)) and invoke this.unscubribeVuex in your beforeDestroy() hoook

    • Watch out if you are not leaving window.myApp = new Vue() references around; nothing in window. gets garbage collected; good idea to set window.myApp = null; in your beforeDestroy hook;

    • Google Chart was also causing a memory leak; I'm pasting the quick fix below, notice in the diffs how we were leaking permanent references to our vue application:

    enter image description here enter image description here enter image description here