Jan 21 - Debugging Electron Memory Usage

This past week I was working on an image browser I’ve been building in Electron, namely trying to
figure out why it would hold onto a bunch of memory.

I use this browser for reference images – high res illustrations, renders or photographs – I’ve
collected. As you navigate directories, the browser loads all the images into a mosaic tile display.
And because this is local, it’s snappy.

The problem is, when I would throw in a search that pulls up all the images, the RSS memory shoots
up: from a 300MB steady state to ~800MB-1GB. Navigating away doesn’t flush this memory. More notable
context: this application has very little state (UI state + list of current images), the images are
the only thing of relevance.

The cheap answer: it’s Electron. And I gave up a few times along the way: there are many layers of
huge technologies, doing their own optimizations and, not being familiar with any of them, makes it
difficult to reason about what’s happening.

The first thing I did: open the developer tools and try everything!

Click to enlarge

Things I’ve learned here:

Memory Patterns:

Memory doesn’t grow indefinitely with use - not a memory leak.

The activity monitor shows the app holds onto memory, even when navigating way from
searches/directories with images.

If I refresh with cmd-R, everything is flushed, including memory used.

Heap:

The javascript heap is “only” 7MB, including what seems to be closures, compiled
code, and representations of DOM elements.

Allocations shows the largest object types have barely a few megabytes

Between loading a heavy directory, I see kilobytes of allocations, not megabytes

Layers have ~150MB? What’s up with that

Site data is all empty, clearing does nothing

Frames has reference to every image I’ve loaded so far?

My hunch is it’s a caching issue or unreleased garbage. Navigating across pages retains the memory,
it grows as images load, but levels off. Given this is a loosely coded prototype, I’m skeptical I
haven’t been shooting myself in the foot without realizing it.

This is another view at the JS heap really, which we yet again see is relatively tiny. Nevertheless,
I tried a periodic forced garbage collection:

functiongc(){global.gc()console.log("gc")}setInterval(gc,10000)

This dropped down the heap (in one attempt from 17MB to 10MB), but otherwise no effect. I think
between this, process.memoryUsage() and the allocations we can have a good sense the problem is
not the js heap, or unretained references.

I also found a suggestion from another user with image caching problems to use clearCache, which
lead me to running the following on the main process (after creating the window):

Which had zero effect. Likewise using session.clearStorageData() had no effect.

Looking at the documentation for
session shows it looks it is a property of
WebContents, which renders and controls the
webpages. Even though that seems promising, session “Manage browser sessions, cookies, cache,
proxy settings, etc.”, which sounds much more like the context for web requests (like login session
in cookies?), and not rendering.

My impression here is used refers to currently live (not garbage), available is something
about available virtual memory address space, and committed is what has already been requested
of the address space.

The things that stick out to me here:

All spaces (mostly driven by old space) continues to grow (larger than my previous heap measures?)

External memory jumps up and then back down

Thus began the Googling. From a link I comment on a github issue (which I sadly cannot find anymore), I learned
RSS is a bad measure, allocators try not to return memory back to the OS when possible (this makes
sense if requesting memory is so expensive). A tour of V8: Garbage
Collection was helpful here. Amongst
many other things, it mentions V8 lazily sweeps:

All this memory doesn’t necessarily have to be freed up right away though, and delaying the
sweeping won’t really hurt anything. So rather than sweeping all pages at the same time, the
garbage collector sweeps pages on an as-needed basis until all pages have been swept.

I resigned to believing that these images get buffered in external memory, V8 freely takes as much
memory as the OS will let it even if it is for garbage, and then even when cleaning up garbage, it
does not release requested memory back to the OS.

Note: In hindsight, now that I know what was actually wrong, I think it’s fair to question these
very hand-wavy assumptions.

Reading the Documentation

A few weeks passed and I came back to the problem, with this nagging feeling that I didn’t really
understand what was happening. After many failed attempts at trying to get node-heapdump set up, I did another sweep of Googling
for similar problems, leading me to read various pages of the available Electron
APIs:

I dug into the on-disk storage to see if there was any disk cache that was getting pulled into
memory.

For a brief moment, I thought NativeImage might be
an optimization for my use case, instead of file:// references.

I retried getting the memory usage for both main and renderer process.

I found Electron has it’s own Process APIs on top of
Node’s, including process.getProcessMemoryInfo() and process.getSystemMemoryInfo(). I
turned these on (same way as process.MemoryUsage(), but they return KB):

Electron will truly eat free memory (documentation defines as “The total amount of memory not
being used by applications or disk cache.”) until it gets pressure from the OS. Maybe this is a
combination of disk cache from loading the images because working set size and previous measures
of RSS don’t match up with how much of free gets used

Tabbing out seems to return a fair amount of memory.

I was ready to resign to my previous hypothesis again: this is just an aggressive allocator.

Web Frames

The next morning, still frustrated, I did a last check on links I had saved, and went through the
devtools again. I had no luck before but I thought I’d try figuring out what’s up with those
“frames” and their links to full res images.

Yay, it’s something! My suspicion is, this being a single page app, there is no navigating away to
flush the cache from the current page. I’ve since added this call on every directory change, and
I’ve seen better drops in RSS: usually from 900MB to 300MB, but in the test I ran for the blog post,
I’m actually still seeing 150MB kept open in external memory. From extra tests I ran, I think these
buffers are temporary however.

From the Electron
source for web
frame,
this method actually deals with the Webkit
WebCache.
There’s a SetCapacity() method that isn’t currently exposed by Electron. I wonder if it would be useful
here, to stop images from ever being cached beyond what’s currently displayed. I looked a bit
further to try to find the logic for image caching, but called it a day.

I at least have a decent idea where this memory is coming from. The experience has given me a few
thoughts (mainly more questions):

Is Memory (RSS) even a performance measure?:

I only noticed this is because I went looking for it. As a user, I can’t tell when this cache
flushing enabled or disabled. Desert Golf, with 18MB of RSS, taxes my computer more than this
application.

Now, I get frustrated with the Slack desktop app as much as the next guy, but I feel like I’m
missing something with RSS as a performance measure. I keep seeing screenshots of excessive memory
use, but if it’s available, what’s the harm? I can think maybe this takes away from disk cache,
maybe it causes extra paging (what if you never reach your memory limits though?), maybe causes
useful memory to be thrown out of CPU caches because of the excessive memory/garbage created> I’m
not sure how much a in-memory cache of unused images would affect these things though. If anyone
has thoughts I would really appreciate learning more about this.

Electron experiences: The good and The Layers of Complexity:

My experience with electron has been really positive. I set out with the idea that this would
be a prototype, and I would move to something else when I need to. So far I haven’t needed to, and
the space of cross-platform GUIs is not as easy, approachable or carefree as flippant comments
about Electron make it seem.

The biggest problem for me was navigating the separate layers of complexity: Chromium, Node, V8
and Electron. Each of these are huge domains in themselves, and (clearly from the rambles of this
post), I fumbled around looking for what exists where, who owns what. Now, I have a much better
sense, and could say that I mistakenly looked at views of the heap usage again and again when it
clearly wasn’t the problem. But to be fair, I think the prospect of having to deal with another
performance issue, say CPU, would be daunting.

I think it’s fair to say such a simple app, could be easily moved to a lighter platform.

I should have asked:

As I can see now, I was a developer working in a domain I don’t have experience in. Clearly there are
people who work on the internals of Chromium, Node and Electron. I really should have sought those
people out, and asked on IRC instead of flailing around, not understanding the fundamental
overview of the pieces involved.

These numbers don’t line up:
You’ll notice in the last log output, RSS is ~900MB and image
live size is over 1.9GB. Not to mention, if I do the same search the app does, I see