A beginner’s guide to creating desktop applications using Electron
By: Uday Hiwarale
Tags: Nodejs, Web Development, Electron, Electronjs, App Development

Architecture of Electron

Electron uses multi-process architecture to manage the application state and user interface. The main process controls the state of the application while the renderer process controls the user interface. Electron uses GPU process to perform graphically intensive tasks but it’s optional.

When an Electron application is launched, the main process is created. We can only have one main process per application. This process can talk to native system APIs and start renderer processes. A renderer process is responsible to display UI using HTML, CSS, and JavaScript.

An Electron application’s entry point is a JavaScript file (let’s call it main.js ) that runs inside the main process. The main process has default access of Node.js APIs but not the chromium APIs. Therefore you can use any Node.js API inside such as fs.writeFile() or require() but not the document or window.addEventListener() .

An Electron application like a browser. The main process is like a browser and renderer processes are like the browser tabs. The main process can open as many browser tabs a possible meaning as many renderer processes as possible. In the Electron world, we call a tab a window since it’s literally an application window as shown below.

(macOS application window)

From the main.js file ( main process), we create a window (like a tab with our previous analogy) by instantiating BrowserWindow class provided by the electron package. This instantiation creates an application window just like above but it will be empty by default.

💡 The electron package provides Electron related modules (such as BrowserWindow ) and high-level system API modules (such as notification, dialogs, touch bar, etc). Modules provided by this package is available in the main as well as in the renderer process but not all modules provided by this package are accessible in these processes (more on that later).

We load an HTML web page inside this window (win) by calling win.loadFile(path) or win.loadURL(url) method. After this call, Electron creates a renderer process and renders the web page from a local file or the remote URL (which could also use the file:// protocol).

// main.js

const { BrowserWindow } = require( 'electron' ); // create a window

const win = new BrowserWindow( { width: 600, height: 300 } );

win.loadFile( './index.html' );

So in a nutshell, JavaScript running in the main process is an orchestrator of the entire application but it can not display UI. It needs to create a renderer process through BrowserWindow interface to do that.

BrowserWindow is just a high-level interface for rendering and controlling the webpage but it itself can not render web pages. The webContents interface is a low-level API that is responsible for rendering and controlling the web page using Chromium’s renderer process.

The renderer process is an operating system process that is associated with the webContents interface. You can access webContents interface from the BrowserWindow interface using win.webContents property. To get the OS PID, use the win.webContents.getOSProcessId() method and to get Chromium’s renderer PID, use win.webContents.getProcessId() method.

💡 Since BrowserWindow is just a high-level interface for the webContents interface, you can use win.webContents.loadFile() call instead of win.loadFile() to render an HTML page inside a window.

Though it seems like there is one renderer process per window, but that’s not always the case. A renderer process is created when we load a web page inside a window. A window may spawn another renderer processing by using <webview> HTML tag (provided by Electron) or adding a BrowserView object to an existing window.

Main vs Renderer Process

It’s pretty clear that the main process manages the application state and the renderer processes control the user interface of the application. Though there are several things renderer process can not have access to.

The main process has access to Node.js APIs by default. While creating a renderer process using new BrowserWindow(options) , we can specify if the renderer process should have access to Node.js APIs as well. This is controlled by the options.webPreferences.nodeIntegration property.

The default value of this property is false so we would need to set it to true if we want JavaScript running inside a renderer process to have access to Node.js APIs such as fs.writeFile() or require() .

💡 The reason why Electron doesn’t grant access to Node.js APIs from the renderer processes by default is that malicious third-party JavaScript code (such as a third-party library) can get access to the user’s system by calling the Node APIs. Therefore you should be careful while using this option.

Also, you can’t create a browser window from another browser window. This means JavaScript running inside a renderer process can’t instantiate new BrowserWindow(options) to create a new window. The creation of windows must be done by the main process.

💡 However a window.open() call will create another window by instantiating BrowserWindow class which will start a renderer process. However, this window has limited functionality as described here.

Another difference is that all native system APIs (outside Node.js) are only accessible from the main process. For example, if you want to open a system dialog, you can only use the dialog module inside the JavaScript running in the main process and not inside a window ( renderer process).

There are a few modules that can be accessed from main and renderer process. Here is a list of modules and APIs that can be accessed from main , renderer and both the processes. You can find documentation of each module on this page.

(Module Accessibility Table / source: gist.github.com)

Inter-Process Communication

Now that we established that main process is for the orchestration of the application as well as for the communication between system resources and the application while the renderer process is for rendering the user interface of the application, we need to figure out how we can deal with the limitations.

For example, as a renderer process can not create another window by instantiating BrowserWindow class, how we can open another application window let’s say when the user presses a <button> element in the window to see let’s say an image with zoom controls. This is where inter-process communication (IPC) comes into the picture.

We can ask the main process to open a window by sending a message from the renderer process or by invoking a function written in the main process (JavaScript running in the main process). Communication between main process and renderer process happens through the IPC modules. The main process can access ipcMain module while renderer process can access ipcRenderer module.

The ipcMain module can listen to events sent by the renderer process using ipcMain.on() function or it can handle function execution invoked by the renderer process using ipcMain.handle() method. Similarly, the ipcRenderer module can send messages to the main process using the ipcRenderer.send() method or invoke a procedure inside the main process using ipcRenderer.invoke() method.

A good example of the main process depending on the renderer process would be the network connectivity status. We can listen to online or offline events in a browser (documentation), so it’s possible to get network connectivity of user’s device in the renderer process but we can’t do the same in the main process. So the only way main process can get the status of network connectivity is by asking the renderer process. Therefore, a renderer process can send a message to the main process every time this status changes (using the ipcMain and ipcRenderer modules).

💡 These communications can happen synchronously or asynchronously depending on the module methods you use.

If you want to communicate between two renderer processes, then you would need to use main process as a bridge or if you have access to webContents interface of a renderer process then you can use webContents.send() method to send a message to another renderer process and use ipcRenderer.on() method to listen to that message.

Background Jobs

If you want to perform a CPU-intensive task, then you wouldn’t want to run the long-running JavaScript code inside a renderer process which would block the UI and leave the UI dead for a few seconds or minutes. However, if you do, let’s say by accident, other windows won’t be affected since they are running in separate renderer processes.

But still, blocking UI is not good as it’s bad for the user experience. What good is your app if the user can not interact with the UI of the application? You would say, let’s use the main processes since it’s not linked with the renderer process but unfortunately blocking the main process would leave your application (and all its windows) dead as well.

To run background jobs, we don’t need to invent the wheel. We already have a few tricks up our sleeves. We can use the WebWorker API from JavaScript running inside renderer process to start a background task. We could also use the worker_threads built-in Node.js module to start a worker thread just like a WebWorker thread.

Some people might suggest using a frameless transparent window that users can’t see to carry out background and CPU-intensive tasks. Here is an article that describes exactly that. However, we should avoid using such tricks when we have better ways to do things.

A web worker does not automatically get access to Node.js API. We need to set options.webPreferences.nodeIntegrationInWorker property to true while creating a BrowswerWindow object. This property works irrespective of the nodeIntegration property.

💡 Though we can access all Node.js APIs inside a web worker, it’s not possible to access Electron’s native APIs. For more info on multithreading in Electron, follow this documentation.

10,000 Foot View

What we discussed so far is just an overview of the Electron’s architecture. If you wanna dig deeper, then you might want to give this a read. Let’s sum up what we have discussed so far with a simplified diagram.