The Horror of Blocking Electron’s Main Process
Update: Somebody gave an in-depth answer on twitter about why this happens and you should read it (after reading this, of course)
Apps need to feel smooth. It’s part of the illusion of software — that what you are looking at is something you’re actually interacting with, not made up by electrical pulses. Whenever I build a feature for Actual, I spend as much time as I can afford to make sure it’s fast. Because I’m short on time, it’s not perfect, but I felt like it was generally in a good place.
Until last Thursday. I pulled up the latest version of the app with my finances which has 2 years worth of data. As I updated my budget, something felt off. The table wasn’t updating fast enough. I poked around some more and my heart sank. Everything was slow. It felt like it begrudgingly performed tasks.
The app is built with Electron, which makes it easy to use web technologies to build desktop apps. Most of my performance work happened outside of it — running components with a huge amount of data in a normal browser. Maybe something in the Electron environment is slowing it down?
I immediately thought of this comment I wrote in the entry point for the app:
// TODO: Evidently, I shouldn’t actually be running all of this on
// the main process. For it to be truly async I need to run it
// within its own process. See
// https://github.com/electron-userland/electron-remote/blob/master/src/renderer-require.js#L42
There are two processes in a basic Electron app: the main process and the renderer process. The main process is where you manage OS-level stuff like windows, and the renderer process is where the UI lives. I had previously misunderstood the role of the main process. Actual has a node backend, and I assumed that I should run the backend in the main process. Last April (almost a year ago!) I started to realize I shouldn’t do that, and wrote the above comment.
Something strange was going on with the app, so maybe this is the root of the problem? Apparently, the main process can block the renderer process rather easily, so they aren’t simply two processes operating asynchronously.
The problem is that I couldn’t find any information as to why the main process would be blocking the UI. The link in the comment above doesn’t make sense anymore, but the project itself solve problems with the remote
module, which I don’t use at all. All the information I could find talked about how the the renderer process is blocked on the main process in certain conditions: certain OS-level APIs are called, transparently invoking modules with remote
, etc. None of which I’m doing.
I wanted a clear answer if the main process always blocks the renderer process when it’s busy. So I wrote the following code to slow it down:
function slowdown() {
for (var i = 0; i < 100000; i++) {
const x = {
y:
Math.ceil(i) +
‘sdsfjdlfjlkMNFONnsdno’.slice(4, (Math.random() * 20) | 0)
};
eval(‘(‘ + JSON.stringify(x) + ‘)’);
}
}
On the frontend, I added a page with a button and a number. When the button is pressed, it invokes slowdown
by sending a message to the backend using the ipcRenderer
module and starts incrementing a number every 100ms. What I want to happen is the number continues to increment while the server slogs through my evil code.
Sure enough, the UI is blocked! The number would update once and then the whole UI would completely block for a few seconds until the server responded. What!? 😲
This reminds me of when I discovered synchronous ajax requests really are that horrible:
I knew exactly why Actual felt slow. Actual is powered by sqlite, and something as simple as updating a transaction triggers several queries (updating account balances, budget categories, budget totals, etc). While I’ve optimized these queries, if running a single sqlite query blocks the UI, there’s no way you’re going to make it smooth.
Excitedly, I moved the backend to its own process and implemented a new way to communicate to it, and it fixed everything! Seriously, the results far exceeded my expectations. I had no idea how badly this was impacting performance! And it only took me 3 hours to do this (thanks to abstracting out the communication layer). Us engineers live for this kind of thing.
Here are a few examples of before and after. Look closely at how the app stutters and janks in the before gif:
It’s hard to show just how different it feels. If you look closely, the after gifs show a much more consistent and snappy experience. (Ignore the fact that just moving around the transactions triggers updates — that could be optimized but honestly I like taking the worst case scenarios and making sure those are fast.)
Technical Deets
A pleasant side effect of this change is the new way the backend and UI communicate. Previously, they talked to each other through Electron’s ipcRenderer and ipcMain APIs (using the appropriate send
method which the documentation says is async, but I guess we have different definitions of async).
Now that the backend is in its own process, I needed a way to have the UI process and backend talk to each other. It seemed silly to make all the messages go back to the main process only to be forwarded to the other. Instead, the backend starts its own websocket server and the frontend connects directly to it.
Not only is this much simpler and more performant, I have total control over how messages are serialized. Apparently the IPC APIs only support JSON-serializable objects (if you send a string it will automatically call JSON.parse
on it). Now I can encode and decode my data with a more efficient strategy. Long-term, the backend may even be native and sending binary messages.
Lastly, it’s very easy for me to develop completely outside of electron. All I need to do is start the backend’s websocket server with node directly, and load the frontend in a browser. I already develop most of the components in the browser using a separate design tool, but doing that may be nice for debugging the whole system.
Downsides
There’s always downsides. The major thing is that unfortunately Actual will take up more memory. We’ve created a second renderer process which is not lightweight. While I haven’t tracked memory closely, a quick test seems to take up about 40MB (going from 150MB to 190MB). There could have simply been an unrelated difference between my tests as well. Regardless, I haven’t done any memory profiling at all so I can probably reduce this with care.
Right now the websocket server listens on a port. There’s a small chance of conflict with something already listening on that port, so I might need to implement a way to choosing a port that’s free. Ideally, I could avoid ports and run it as a local socket somehow (via a file descriptor) but I’m not sure node’s websocket libraries handle that.
Conclusion
Keep as much as you can off the UI thread. Even when you do that, make sure that the UI thread isn’t synchronously blocking on anything else.