From Web to Desktop: Introducing Electron Framework for Web Developers
As an application developer, targeting desktop development comes with challenges, such as multiple versions of Windows, macOS, and Linux, each with its own quirks, like API availability and toolchain preferences. Unless you have a well-resourced team, cross-platform development can be difficult. However, this is where Electron comes in.
In this article, we’ll explore what makes Electron popular, its architecture and functioning, performance strategies, installation and packaging, updated information, and some of the drawbacks or controversies some developers dislike.
What is Electron?
Electron is an open-source framework with out-of-the-box compatibility for JS and Node ecosystems. It is built on two incredibly powerful technologies: Node and chromium. The use of Electron has greatly simplified the process of creating cross-platform desktop applications.
GitHub maintains an Electron repository with an active community, making it one of the most widely used frameworks for building cross-platform applications.
Some of the most popular applications that we developers use in our day-to-day life are built using Electron, such as Visual Studio Code, Slack, GitHub desktop, WhatsApp desktop, etc.
What makes it stand out:
Electron stands out for several reasons, including:
- Cross-platform compatibility: Electron allows developers to write a single code base that can run on Windows, macOS, and Linux, simplifying maintenance and development.
- Web technologies: You can build an Electron app with simple HTML, CSS, and JS. Or you can use any web technology that you are familiar with, like Angular, React, etc.
- Auto update and installation: Electron provides these services for free.
- Vast community: As mentioned earlier, GitHub officially manages Electron’s repository and has a very active community, which means developers can rely on vast resources and libraries for help.
- A large number of prebuild modules: Electron provides access to native APIs through its modules, allowing developers to create apps with native-like performance and access to system resources.
Architecture of Electron
Electron adopts a multi-process architecture, similar to modern web browsers, as it is based on Chromium. Its rendering library, Libchromiumcontent, is also borrowed from Chromium. As a result, the process architecture of Electron resembles Chromium in terms of how various processes interact with each other.
Browsers have a complex architecture that encompasses much more than just UI rendering. They also manage various processes in the background, such as handling different tabs, storing data, and loading multiple extensions.
Previously, browsers operated on a single process, which caused frequent problems. For example, if a single tab crashed or became unresponsive, it would negatively impact the entire browser. To resolve these issues, a multi-threaded or multi-process model was implemented. This means that each tab now runs on its own thread, thereby limiting the impact of any issues to a single tab. The diagram below depicts the multi-process architecture.
Electron has two main processes: the main process and the renderer process. It also has an optional GPU process for handling graphically intensive tasks. This architecture is similar to Chrome’s process manager and its multiple processes. When a web application is rendered, the browser first loads the DOM/HTML. The starting point for the web application is usually an HTML file.
Now, let’s take a closer look at how Electron’s architecture works.
Main Process
Electron has a single main process. The starting point of an Electron application is a JavaScript file that runs within the main process. The main process operates in a Node.js environment, meaning it has access to all Node.js APIs but not chromium APIs. For example, you can use Node.js APIs such as fs.readSync() or require(), but no window.createElement() or document. Additionally, the main process has access to native APIs and can manage the application’s windows, lifecycle, etc.
The primary responsibility of the main process is to create and manage windows using the BrowserWindow class. The main.js file (main process) can create a window by instantiating BrowserWindow. Here’s a code snippet for creating a window:
import { BrowserWindow } from 'electron';
const win = new BrowserWindow({
x: 0,
y: 0,
width: 1024,
height: 720,
})
win.loadFile('./index.html')
The main process is the coordinator of the entire application, but it cannot render the user interface. To handle this task, it creates a renderer process using the BrowserWindow class.
BrowserWindow is a high-level interface for creating and controlling the renderer process. The webContents interface is a low-level API that renders and controls web pages using the Chromium renderer process. You can access the webContents interface from the BrowserWindow interface using win.webContents property. The main process also controls the application’s life cycle using the app, as demonstrated in the code snippet below:
// app.whenReady() even gets triggered after electron is initialized
app
.whenReady()
.then(() => {
createWindow();
})
.catch((error) => {
logger.errorHandler(error, 'main');
});
// This event will be triggered in primary instance of application when you start second instance
app.on('second-instance', () => {
if (win) {
if (win.isMinimized()) {
win.restore();
}
win.focus();
}
});
// This event emitted right before application window starts closing
app.on('before-quit', () => {
childProcess.kill('SIGINT');
});
// This event emitted when all windows have been closed
app.on('window-all-closed', () => {
if (process.platform !== "darwin") {
app.quit();
}
});
//Emitted when application is activated
app.on('activate', () => {
if (win === null) {
createWindow();
}
});
Electron has many other events available, which you learn more about here.
In contrast to web applications, Electron has native APIs that allow interaction with the user’s operating system. These APIs make it easy to create the application’s menu, notifications, dialogs, tray content, icons, etc. Let’s take a look at some of Electron’s native APIs.
- Tray:
In an Electron application, the tray refers to the area in the operating system’s taskbar where an application can place an icon.
const { Tray, Menu } = require('electron');
const tray = new Tray('/path/to/icon.png');
const menu = Menu.buildFromTemplate([
{
label: 'Open Dashboard',
id: 'dashboard',
click: () => {
...
},
},
{
label: `Version ${version}`,
enabled: false
}
])
tray.setContextMenu(menu)
- Dialog:
Dialogs in Electron provide standard dialog boxes for displaying messages, requesting input, or prompting decisions from the user.
const { dialog } = require('electron');
const option = {
type: 'question',
icon: path.join(__dirname, 'icons', 'iconx64.png'),
buttons: ['Later', 'Install and Relaunch'],
defaultId: 0,
message: 'A new version has been downloaded!',
};
dialog
.showMessageBox(option)
.then(async (response) => {
if (response.response) {
win.webContents.send('installUpdate', { installUpdate: true });
} else {
win.show();
}
})
.catch((error) => {
logger.errorHandler(error, 'main');
});
- Notification:
Notifications in Electron show alerts to notify the user of events or display information.
const { Notification } = require('electron');
new Notification({
title: 'Notification title!',
body: 'You can pass notification body here.',
});
Renderer Process
The renderer process in Electron is responsible for rendering the user interface to the user. It operates independently from the main process, and these two processes communicate through inter-process communication (IPC). The renderer process runs its own JavaScript code, accesses DOM elements, and utilizes web APIs. This separation offers improved security and stability for the application.
It might seem like there is only one renderer process per window, but it is not always the case. The renderer process is created when we load a page inside a window. A window may spawn another renderer process by using WebView or by adding a browserView object to an existing window.
Renderer process does not have direct access to NodeJS APIs. In order to access NodeJS API in the renderer, you have to use some bundler toolchain used in the web app, or you can set the nodeIntegration flag to true while creating BrowserWindow.
Preload script
Preload script in Electron is used to bridge main and renderer processes together.
The preload script in Electron is a JavaScript file loaded and executed before the renderer process loads the main HTML file. It allows developers to add custom behavior to the renderer process and is executed within the context of the renderer process. The preload script is specified in the webPreferences option when creating a new BrowserWindow.
win = new BrowserWindow({
x: 0,
y: 0,
width: 1024,
height: 720,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
}
})
To add additional functionalities to the renderer process, you can pass a global object through contextBridge. To use a few NodeJS APIs in the renderer process, we don’t have to expose the whole NodeJS APIs by enabling the nodeIntegration flag. Instead, we can expose functionality by adding a specific NodeJS API in the preload script. Let’s take a quick look at the following implementation.
//preload.js
constchild_process = require('child_process')
const { contextBridge } = require('electron')
contextBridge.ExposeInMainWorld('api', {
childProcess: (command) => child_process.exec(command)
})
Let’s assume we want to access the NodeJS child_process method in our renderer process. As we know, NodeJS APIs are not available in the renderer, so we need to make them available through the preload script.
Here, we can easily use the NodeJS child_process module in the renderer process, as shown in the above code snippet.
By using the preload script, developers can improve their Electron application’s functionality and enhance the performance and security of the renderer process. You can read more about preloads and inter-process communication in the Electron documentation.
Background Jobs
Long-running and CPU-intensive tasks running inside the renderer process can block the UI for seconds or minutes, negatively impacting the user experience. Blocking the UI is not a desirable outcome.
Similarly, running heavy operations in the main process is also not preferable, as the main process consists of single-threaded JavaScript code.
To run background jobs more efficiently, we can use the WebWorker API from JavaScript within the renderer process. Another option is to use worker_threads from NodeJS, which allows us to start a new thread, much like WebWorkers.
The third option for offloading tasks from the main thread is to add a frameless, transparent browser window that is not visible to the user. This acts as a hidden renderer that performs background tasks while freeing up the main thread to maintain a smooth UI experience.
// main.js
// create a hidden worker window
const workerWindow = new BrowserWindow({
show: false,
webPreferences: {
...
}
});
workerWindow.loadFile('worker.html');
//worker.html
<!DOCTYPE html>
<html>
<head>
<script>worker script</script>
</head>
<body></body>
</html>
You can also create a separate thread by using the child_process module.
Here is an example of one of the techniques, child_process, that is described above to offload some CPU-intensive tasks from a single thread.
import { spawn } from 'child_process';
const childProcess = spawn(path.join(__dirname, '..', 'index.exe'));
childProcess.stdout?.on('data', (data) => {
console.log(` Message from background task: ${data} `);
});
childProcess.stderr?.on('error', (error) => {
console.log('Something went wrong');
});
It’s possible to access NodeJS APIs within a web worker, but it’s not possible to access Electron’s native APIs. This is because web workers do not have direct access to NodeJS. In order to allow access to NodeJS within a web worker, you must set the nodeIntegrationInWorker flag to true when creating a BrowserWindow.
For more information on multi-threading and its implementation in NodeJS, refer to this article.
Security concerns
Like any other technology, Electron has its own security concerns, such as Cross-Site Request Forgery (CSRF), security misconfigurations, Cross-Site Scripting (XSS), etc.
Unlike other basic web applications, Electron has many APIs while developing the application. Plus, it also has access to the NodeJS module, which comes with security risks. For instance, if an attacker finds a means to introduce malicious JavaScript into the program, they may be able to run system commands on the victim’s computer.
Let’s see some of the most common security concerns in Electron:
Cross-Site Scripting (XSS) attacks: XSS attacks occur when an attacker injects malicious code into a web page that is displayed in a renderer process. To reduce the risk of XSS attacks, it’s important to validate and sanitize any data used on the web page and implement appropriate security measures such as Content Security Policy (CSP).
IPC vulnerabilities: The Inter-Process Communication (IPC) mechanism used by Electron can be exploited by attackers to gain access to sensitive information or to execute malicious code. It’s important to validate the data received through IPC to ensure it’s safe.
Adhering to software security best practices, such as verifying all input and output and following best practices for online security, is essential to lowering the risk of security vulnerabilities in Electron apps. Additionally, it’s critical to keep dependencies current and to be updated about security updates and fixes for dependencies like Electron, Chromium, Node.js, and others.
Refer to this document to learn more about securing Electron apps.
Controversy about Electron
Despite having great benefits like open-source, coding once and deploying on different OS, vast community, Electron receives some criticism from developers in terms of high memory and storage usage.
Electron uses chromium, which is memory intensive. Electron ships every application with separate chromium runtime and Nodejs, which is not lightweight either. And having both chromium and Nodejs together comes with some fixed cost (~130MB), regardless of whether multiple electron applications have the same version of NodeJS and chromium.
Solution: We can extract single runtime across OS and all applications to create a lightweight final binary. Some alternative projects, such as Electrino and Tauri, are designed to address the drawbacks of Electron, but they come with some costs, such as the lack of extra APIs and the need to write low-level binaries for additional features.
Electron applications consume a minimum of 100MB of RAM and go even higher if the application is large.
Luckily, using best practices and well-structured architecture can help develop a potential application using Electron.
Some Electron applications may be slow depending on factors like overuse of animations, poor code base, etc. One other drawback is you cannot give your application a native application-like look (lack of native UI/UX). Applications made using Electron have a generic look and feel.
Takeaway
With Electron, developers can leverage their knowledge of JavaScript, avoiding the need to learn a new language. Using a single code base, developers can build application artifacts that work seamlessly across different platforms, making the process of building cross-platform applications a lot easier.
Electron also provides an auto-update feature that ensures apps are up-to-date, making it easier to manage and maintain applications.
In addition to these benefits, Electron is a powerful, efficient, and secure technology for building desktop applications, making it an excellent choice for developers looking to build cross-platform desktop applications.