Introducing fuite: a tool for finding memory leaks in web apps

Debugging memory leaks in web apps is hard. The tooling exists, but it’s complicated, cumbersome, and often doesn’t answer the simple question: Why is my app leaking memory?

Because of this, I’d wager that most web developers are not actively monitoring for memory leaks. And of course, if you’re not testing something, it’s easy for bugs to slip through.

When I first started looking into memory leaks, I assumed it was a rare thing. How could JavaScript – a language with an automatic garbage collector – be a big source of memory leaks? But the more I learned, the more I suspected that memory leaks were actually quite common in Single Page Apps (SPAs) – it’s just that nobody is testing for it!

Since most web developers aren’t fiddling with the Chrome memory tools for the fun of it, they probably won’t notice a leak until the browser tab crashes with an Out Of Memory error, or the page slows down, or someone happens to open up the Task Manager and notice that a website is using many megabytes (or even gigabytes!) of memory. But at that point, it’s gotten bad enough that there may be multiple leaks on the same page.

I’ve written about memory leaks in the past, but my advice basically boils down to: “Use the Chrome DevTools, follow these dozen tedious steps, and then maybe you can figure out why your page is leaking.” This is not a great developer experience, and I’m sure many readers just shook their heads in despair and moved on. It would be much better if a tool could find memory leaks automatically.

That’s why I wrote fuite (French for “leak”). fuite is a CLI tool that you can point at any URL, and it will analyze the page for memory leaks:

npx fuite https://example.com

That’s it! By default, it assumes that the site is a client-rendered SPA, and it will crawl the page for internal links (such as /about or /contact). Then, for each link, it runs the following steps:

  1. Click the link
  2. Press the browser back button
  3. Repeat to see if memory grows

If fuite finds any leaks, it will show which objects are suspected of causing the leak:

Test         : Go to /foo and back
Memory change: +10 MB
Leak detected: Yes

Leaking objects:

| Object            | # added | Retained size increase |
| ----------------- | ------- | ---------------------- |
| HTMLIFrameElement | 1       | +10 MB                 |

Leaking event listeners:

| Event        | # added | Nodes  |
| ------------ | ------- | ------ |
| beforeunload | 2       | Window |

Leaking DOM nodes:

DOM size grew by 6 node(s)  

To do this, fuite uses the basic strategy outlined in my blog post. It will launch Chrome, run some scenario n number of times (7 by default) and see if any objects are leaking a multiple of n times (7, 14, 21, etc.).

fuite will also analyze any Arrays, Objects, Maps, Sets, event listeners, and the overall DOM to see if any of those are leaking. For instance, if an Array grows by exactly 7 after 7 iterations, then it’s probably leaking.

Testing real-world websites

Somewhat surprisingly, the “basic” scenario of clicking internal links and pressing the back button is enough to find memory leaks in many SPAs. I tested fuite against the home pages for 10 popular frontend frameworks, and found leaks in all of them:

Site Leak detected Internal links Average growth Max growth
Site 1 yes 8 27.2 kB 43 kB
Site 2 yes 10 50.4 kB 78.9 kB
Site 3 yes 27 98.8 kB 135 kB
Site 4 yes 8 180 kB 212 kB
Site 5 yes 13 266 kB 1.07 MB
Site 6 yes 8 638 kB 1.15 MB
Site 7 yes 7 1.37 MB 2.25 MB
Site 8 yes 15 3.49 MB 4.28 MB
Site 9 yes 43 5.57 MB 7.37 MB
Site 10 yes 16 14.9 MB 186 MB

In this case, “internal links” refers to the number of internal links tested, “average growth” refers to the average memory growth for every link (i.e. clicking it and then pressing the back button), and “max growth” refers to whichever internal link was leaking the most. Note that these numbers don’t include one-time setup costs, as fuite does one preflight iteration before the normal 7 iterations.

To confirm these results yourself, you can use the Chrome DevTools Memory tab. Here is a screenshot of the worst-performing site from my set, where I click a link, press the back button, take a heap snapshot, and repeat:

Screenshot of the Chrome DevTools memory heapsnapshots list, showing memory starting at 18.7MB and increasing by roughly 6MB every iteration until reaching 41 MB on iteration 5

On this particular site, memory grows by about 6 MB every time you click a link and go back.

To avoid naming and shaming, I haven’t listed the actual websites. The point is just to show a representative sample of some popular SPAs – the authors of those websites are free to run fuite themselves and track down these leaks. (Please do!)

Caveats

Note, though, that not every leak in an SPA is an egregious problem that needs to be addressed. SPAs need to, for example, maintain the focus and scroll state to properly support accessibility, which means that there may be some small metadata that is stored for every page navigation. fuite will dutifully report such leaks (because they are leaks), but it’s up to the developer to decide if a tiny leak is worth chasing or not.

Some memory growth may also be due to browser-internal changes (such as JITing), which the web page can’t really control. So the memory growth numbers are an imperfect measure of what you stand to gain by fixing leaks – it could very well be that a few kBs of growth are unavoidable. (Although fuite tries to ignore browser-internal growth, and will only say “leaks detected” if there is actionable advice for the web developer.)

In rare cases, some memory growth may also be due to outright browser bugs. While analyzing the sites above, I actually found one (Site #4) that seems to be suffering from this Chrome bug due to <img loading="lazy"> not being unloaded. Unfortunately it’d be hard for fuite to detect browser bugs, so if you’re mystified by a leak, it’s good to cross-check against other browsers!

Also note that it’s almost impossible for a Multi-Page App (MPA) to leak, because the browser clears memory on every page navigation. (Assuming no browser bugs, of course.) During my testing, I found two frontend frameworks whose home pages were MPAs, and unsurprisingly, fuite couldn’t find any leaks in them. These were excluded from the results above.

Memory leaks are more of a concern for SPAs, where memory isn’t cleared automatically on each navigation. fuite is primarily designed for SPAs, although you can run it on MPAs too.

fuite currently only measures the JavaScript heap memory in the main frame of the page, so cross-origin iframes, Web Workers, and Service Workers are not measured. Something like performance.measureUserAgentSpecificMemory() would be more accurate, but it’s only available in cross-origin isolated contexts, so it’s not practical for a general-purpose tool right now.

Other memory leak scenarios

The “crawl for internal links” scenario is just the default one – you can also build your own. fuite is built on top of Puppeteer, so for whatever scenario you want to test, you essentially just need to write a Puppeteer script to tell the browser what to do. Some common scenarios you might test are:

  • Open a modal dialog and then close it
  • Hover over an element to show a tooltip, then mouse away to dismiss it
  • Scroll through an infinite-loading list, then navigate away and back
  • Etc.

In each of these scenarios, you would expect memory to be the same before and after. But of course, it’s not always so simple with web apps! You may be surprised how many of your dialogs and tooltips are harboring memory leaks.

To analyze leaks, fuite captures heap snapshot files, which you can load in the Chrome DevTools to inspect. It also has a --debug mode that you can use for more fine-grained analysis: stepping through the test as it’s running, debugging the browser in real-time, analyzing the leaking objects, etc.

Under the hood, fuite is a fairly basic tool, and I won’t claim that it can do 100% of the work of fixing memory leaks. There is still the human component of figuring out why your objects were allocated and retained, and then finding a reasonable fix. But my goal is to automate ~95% of the work, so that it actually becomes achievable to fix memory leaks in web apps.

You can find fuite on GitHub. Happy leak hunting!

Update: I made a video tutorial showing how to debug memory leaks with fuite.

18 responses to this post.

  1. This is epic! Just found some leaks on my blog. Nolan – you rock!

    Reply

  2. Posted by Ralph Haygood on December 17, 2021 at 6:06 PM

    Very helpful. Thanks.

    I see one kind of output I don’t immediately understand. For example:

    Test : Go to /log_in and back
    Memory change: -147 kB
    Leak detected: No

    Leaking collections:

    Collection type
    Size increase
    Preview

    Object
    2
    {undefined: {scrollPosition}, …}

    Really, memory change is negative?

    No big deal, just curious what that means. (Some idiosyncrasy of my code?)

    Reply

    • Hm, that may be a bug. Is this a multi-page app? Typically negative memory changes should only occur with multi-page apps. I’m not sure how it’s possible for it to show leaking collections in that case, though.

      Reply

      • Posted by Ralph Haygood on December 17, 2021 at 9:34 PM

        It’s what might be called a pseudo-SPA using Vue and Rails with Turbolinks. (In case you aren’t acquainted with Turbolinks: it converts page loads into XHRs, so the JavaScript environment persists across them.) fuite does find memory leaks on another page of the same app. (They turn out to be due to a known defect of Vue’s so-called functional components in development environments with hot module reloading, so they disappear when I point fuite at the production version of the app.)

      • Interesting, maybe your Turbolinks site truly has no leaks. If it lost 147 kB that’s not unheard of; something may have gotten GC’ed between the start and the end. (Although fuite runs GC before every heap snapshot, browsers and web pages are complicated, so maybe some cleanup logic ran after that.)

        As for the “scrollPosition” object, it’s probably a “true” leak (the SPA keeping track of the scrollPosition for every navigation), but so small that it doesn’t actually cause a memory increase. (This makes sense, especially if it’s just storing an integer or something.) I’d just ignore it.

        You can try re-running with --iterations 13 or --iterations 17 to see if you get similar results.

  3. Posted by Ralph Haygood on December 17, 2021 at 9:46 PM

    if you want to try it, the development version is https://ralph.msrlims.net.

    Reply

    • Posted by Ralph Haygood on December 18, 2021 at 6:19 AM

      Sorry this didn’t thread properly. I thought I was replying to your (Nolan Lawson’s) reply above beginning “Hm, that may be a bug.”

      To add a bit, for whatever it’s worth: the negative memory changes happen with the development version of the app, not the production version, for which every test yields a small positive change. The latter is partly due to Turbolinks caching, which I experimented with turning off to see what difference it made. The production version is at

      https://staging.msrlims.net

      As the subdomain indicates, this is really a staging version, which is isomorphic to the true production version.

      Reply

      • Ah okay. Yeah I analyzed it (here’s how I do it), and the leaking object is just some object that seems to be holding onto the scroll position or something. Probably this is Turbolinks doing this.

        {
            "0c0317a6-d7dd-4dd5-ac2a-73654b2391ab": {},
            "11e0bd30-691a-4093-b3ab-33eb91522870": {},
            "1231229c-6c87-44b8-97bd-3a021132042b": {},
            "2248e5a2-d818-4581-a774-b133d70b0884": {},
            "229173c3-5472-4d8a-8359-8c2c3341537d": {},
            "2dda5b61-c332-4eec-a06e-cbae55825668": {},
            "31cb8cc8-dc68-49c9-827a-9ab05b8849d1": {},
            "424b01ba-5b26-449a-be01-1c3a369c0606": {},
            "640e5306-c4a6-4601-add7-21ed9d822729": {},
            "64199de5-d468-47ca-9626-a4959956e0b7": {},
            "759cad1d-1d91-482a-abe2-70912c7de76e": {},
            "918e75e6-9759-4caa-b89b-231907944dc5": {},
            "95ba1e1d-4314-4b0a-a3db-c67b1b4ad249": {},
            "9c5e3b43-1649-4b8c-842a-15196c3daa3d": {},
            "cc6213d7-d1c7-4465-b2b1-715472b57438": {},
            "ce457cb3-90ba-4bd6-89bc-14bd1e7973c0": {},
            "undefined": {
                "scrollPosition": {
                    "x": 0,
                    "y": 0
                }
            }
        }
        

        This object grows by 2 every time you navigate somewhere and then go back. But since it’s only leaking a string and an empty object, it’s probably so small that it doesn’t matter.

        When I test, I’m seeing memory changes of between roughly -70kB and +40kB. These numbers are tiny, so it’s probably just noise and you can ignore it. :)

      • Posted by Ralph Haygood on December 18, 2021 at 12:58 PM

        Thanks.

      • I was curious, so I actually managed to find the line in Turbolinks that is adding to the object. It’s called getRestorationDataForIdentifier, and it’s calling this.restorationData[identifier] = {}; which is increasing the size of the restorationData object.

  4. […] Introducing fuite: a tool for finding memory leaks in web apps […]

    Reply

  5. Pages can also leak (badly) when “idle” sitting on a page, or scrolling around it in, not just clicking on things. For example, a very popular anti-clickfraud supplier’s code, embedded on tons of high-profile sites, was saving encoded strings representing some type of state. After letting (say) WashingtonPost.com sit for a day, it would have created (say) 100K identical several-hundred-character-long strings. I managed to eventually contact someone technical there, and they promised to fix it. Took 6-9 months for them to actually do so….

    Many sites leak large objects in maps or arrays over time.

    Facebook used to leak “<div” strings by the 10’s of thousands if you lead a message up for hours or days. Sites with things like live feeds of stuff sometimes will never clean them out (i.e. they expand forever). Etc.

    Reply

  6. […] There’s also another angle to this: Firefox is made up of several processes and can survive losing all of them but the main one. Delaying a main process crash might lead to another process dying if memory is tight. This is good because it would free up memory and let us resume execution, for example by getting rid of a web page with runaway memory consumption. […]

    Reply

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.