Deep dive into Electron’s main and renderer processes

Understanding Electron's multi-process architecture is a first step to writing good Electron apps

· 5 min read
A scuba diver swimming in deep water

Central to Electron is the concept of two or more operating system level processes running concurrently — the “main” and “renderer” processes. Dealing with multiple processes is new territory if you’re coming from browser Javascript land. It was definitely a paradigm shift for me initially, and working with multiple processes may mean you make different design choices in your app that you wouldn’t otherwise.

Why does Electron have this multi-process architecture? What are the responsibilities of the main process? What are the responsibilities of a renderer process? How do we communicate between them?

First off, what do you mean “process”?

I mean an operating system level process, or as Wikipedia puts it “an instance of a computer program that is being executed”. Crystal clear, right? For example, if I start an Electron application and then check the Activity Monitor in macOS, I can see how many processes are associated with that program.

“Electron” is the main process, one “Electron Helper” is a GPU process, and the other “Electron Helpers” are renderer processes.“Electron” is the main process, one “Electron Helper” is a GPU process, and the other “Electron Helpers” are renderer processes.

Each of these processes run concurrently to each other. The most important thing to remember here is that processes’ memory and resources are isolated from each other. So for example, let’s say I have a module that holds some state that I require in both my main and renderer process:

If I increment in my renderer, the count in the renderer will be 1, but it’ll still be 0 in the main process. The two processes don’t share memory or state. There are literally two instances of that module running. This might be obvious to some but it wasn’t to me, so there you have it.

Why multiple processes?

This architectural decision originates from Chromium. Chromium runs each tab (i.e. webContents instance) in a separate process so that if one tab runs into a fatal error, it doesn’t bring down the entire application. In this sense, “Chromium is built like an operating system, using multiple OS processes to isolate web sites from each other and from the browser itself.” So each process “runs in its own address space, is scheduled by the operating system, and can fail independently.” I think we’ve all accidentally written an infinite loop before that brings down the tab it’s running in, but not the entire browser. This architecture is to thank for that resilience. There are also security reasons. Chromium has pretty interesting documentation on it’s multi-process architecture here:

Main process

The main process is responsible for creating and managing BrowserWindow instances and various application events. It can also do things like register global shortcuts, create native menus and dialogs, respond to auto-update events, and more. Your app’s entry point will point to a JavaScript file that will be executed in the main process. A subset of Electron APIs (see graphic below) are available in the main process, as well as all node.js modules. The docs state: “The basic rule is: if a module is GUI or low-level system related, then it should be only available in the main process.” (Note that GUI here means native GUI, not HTML based UI rendered by Chromium). This is to avoid potential memory leak problems.

Renderer process

The render process is responsible for running the user-interface of your app, or in other words, a web page which is an instance of webContents. All DOM APIs, node.js APIs, and a subset of Electron APIs (see graphic below) are available in the renderer.

I used to conflate a BrowserWindow with a renderer process. A renderer process isn’t actually created until a window has a webContents instance in it. That’s sort of a nit-picky point to make but I think it’s important and knowing it leads to a more solid conceptual foundation. Also, one or more webContents can live in a single window. Wait, one or more? Yes, because a single window can host multiple webviews and each webview is its own webContents instance and renderer process. So for example, if you have a page with 2 webviews in it, you’ll have 3 renderer processes — one for the parent hosting the 2 webviews, and then one for each webview. That being said, you don’t need to use webviews unless you want to run a remote web page, so don’t get too hung up on that detail.

This Venn diagram shows the Electron APIs available in each process type. You can also see that Node.js APIs are available globally, while only DOM/Browser APIs are available in a renderer.This Venn diagram shows the Electron APIs available in each process type. You can also see that Node.js APIs are available globally, while only DOM/Browser APIs are available in a renderer.

So, if we wanted a <button> click (that’s HTML so its in a renderer process) to open a native dialog (that API is only available in the main process), what do we do? This question could be rephrased as…

How do I communicate between processes?

Electron uses interprocess communication (IPC) to communicate between processes — same as Chromium. IPC is sort of like using postMessage between a web page and an iframe or webWorker. Basically, you send a message with a channel name and some arbitrary information. IPC can work between renderers and the main process in both directions. IPC is asynchronous by default but also has synchronous APIs (like fs in Node.js).

Electron also gives you the remote module, which allows you to, for example, use a main process module like Menu as if it were available in the renderer. No manual IPC calls necessary, but what’s really going on behind the scenes is that you are issuing commands to the main process via synchronous IPC calls.

Using the devtron module, we can observe all IPC calls that happen when you use the remote module. Synchronous IPC calls may have performance drawbacks. For many cases its probably fine though.

Several synchronous IPC calls happen when using `remote` to open a dialog.Several synchronous IPC calls happen when using remote to open a dialog.

The code for working with the ipc or remote APIs is pretty straightforward. A basic example can be found here: ccnokes/electron-tutorials - Collection of small sample Electron apps.

Can I make something work in both the main and renderer?

Yes, because main process APIs can be accessed through remote, you can do something like this:

Is IPC using some networking protocol (e.g. tcp, http, or something crazier) underneath the hood?

Nope. Chromium’s IPC documentation states that it uses “named pipes” as the underlying vehicle for IPC. Named pipes allow for faster, more secure communication than a networking protocol could provide. “Named pipes” are similar to “unnamed pipes” which are used when you do something like ls | grep foo. Named pipes are fun to play around with and you can see an example here:

So where do I do CPU intensive work?

I used to think the main process is the ideal place for “heavy lifting” because it wouldn’t block the UI. That’s wrong actually — if you do CPU intensive work in the main process, it’ll lock up all your renderer processes (and give you the infamous beachball on macOS). So CPU intensive tasks should run in a separate process — not an existing renderer with a UI in it (because it’ll lock that UI up) and not the main. The easiest way to do this is with Electron-remote. Electron-remote is pretty awesome and has a renderer process task pool that will split and balance a job across multiple processes.

Here’s a quick example:

Underneath the hood, electron-remote creates up to 4 BrowserWindow instances that all require your module and run it, and then orchestrates communicating the back and forth between the processes.

Well, that’s it! Hopefully this has helped deepen your understanding of Electron’s multi-process architecture and how to work with it.