Editor’s note: As of 5 October 2023, this article has been updated to include information about security considerations, performance optimization with Electron.js, scalability, and modular design.
A while back, I began working on a side project called taggr, a completely offline, interactive photo exploration app. Developing taggr required me to navigate up from the lowest level of app complexity, trying out multiple architectural approaches and exploring the limitations of each.
In this article, we’ll discuss the trade-offs of different architectural approaches for building desktop applications with Electron.js. We’ll analyze the shortcomings of each and introduce an architecture that aims to tackle them.
The blueprint presented in this article is the outcome of an ongoing effort to find an approach that enables me, a solo developer, to manage the complexity of the app and meet performance requirements by leveraging standard web tooling. You can follow along with this GitHub repository. Let’s dive in!
Jump ahead:
In the last few years, JavaScript usage has dramatically increased within the browser realm, largely with the help of libraries and frameworks like React, Vue, and Angular. Similarly, we’ve seen JavaScript grow beyond the browser with Node.js, Deno, and React Native.
Electron.js is one of these frameworks. Since its release in 2013, Electron has grown to become one of the most-used frameworks for building cross-platform desktop applications. VS Code, Slack, Twitch, and many other popular desktop applications are built using Electron.
Electron embeds Chromium and Node.js in its binary, enabling web developers to write desktop applications without writing native code. Electron implements a multi-process model composed of the main and renderer processes, which is similar to the Chromium browser.
Each application’s window is a render process, which isolates the code execution at the window level. The main process is responsible for the application lifecycle management, window management or render process, and native APIs like system menus, notifications, and tray icons.
Each app is composed of one main process and a variable number of render processes. Render processes can be used for JavaScript code execution and can be hidden without a UI:
Note: Electron is not the only option for building cross-platform desktop applications. Other alternatives offer less resource consumption and lighter executables, but none share the community, learning resources, or widespread adoption of Electron.
If you aren’t already familiar with Electron, it’s pretty easy to get started, especially because knowledge of Node.js and JavaScript is transferrable.
Electron provides abstractions and a familiar language, reducing the time to market and development costs. Essentially, what Electron does for desktop app development is similar to what React Native does for mobile development.
Electron also manages building and deploying app updates, making it easy to keep cross-platform apps in a synced version. You can achieve this with auto-updates and by loading remote assets at runtime.
However, it’s essential to consider the trade-offs, as Electron apps (by embedding Chromium and Node.js environments) tend to consume more resources than native implementations, but the continuous improvements and fixes are working towards mitigating these concerns and enhancing performance and resource efficiency.
In addition, complex Electron apps present performance and developer experience challenges related to the underlying architecture. Let’s consider these trade-offs in depth by analyzing three different app examples.
Let’s examine the high-level architectures of three fictional apps with varying complexity. Bear in mind that our app analysis does not aim to be exhaustive, rather, it aims to tease potential apps that you can build with Electron.
Let’s start with a low-complexity app. For our example, we’ll consider packaging a webpage as a desktop application. Examples can include instant messaging apps, data analysis dashboards, and online streaming apps.
Many businesses provide desktop versions of their successful web-based apps, making ours a common use case. We’ll use Electron to run the app on Chromium, eliminating unnecessary polyfills and providing a unified UI instead of a heterogeneous browser landscape.
The main features of the low-complexity app will include the following:
As an example architecture, we’ll use a desktop app for the Telegram chat web app. Electron will act as a wrapper for the existing web app without requiring any changes to the backend:
Setting up Electron is easy for this type of app! There are no changes needed at the web app codebase level.
A music streaming app like Spotify, which offers offline streaming support using a local cache, is a typical example of an app with a medium level of complexity. The desktop app can use Electron to build a local cache layer.
Similar to low-complexity apps, a medium-complexity app may also complement a web app. The main difference is the ability to provide offline support. Therefore, these apps are conceptually related to progressive web apps (PWAs) with offline support.
The main features of these apps include:
Let’s imagine that our streaming app plays a song of the day. If there is no internet connection, it will serve the available cached song:
As outlined in the schema above, the UI will be served from local assets instead of a CDN, and the request layer has to be customized to support caching. While the example is relatively simple, the code-sharing and caching requirements will eventually increase in complexity, requiring custom Electron code.
For the highest level of complexity, let’s look at a batch image processing app like sharp. The app must be able to process thousands of images and work entirely offline.
Offline apps are significantly different from the previous two examples. Specifically, the typical backend workloads, like image processing, will execute within Electron by creating an offline application.
Main features include:
For the architecture proposal, let’s consider the offline image processing app described above, and demonstrated in the following diagram:
The schema structures the app following the Electron documentation, which has some limitations. For one, there is noticeable performance degradation when running long-lived, CPU-intensive operations in a hidden renderer process.
Note that you should never run the operations in the main process. Doing so may block the main process, causing your application to freeze or crash.
Additionally, coupling the business logic and transport layers to Electron APIs limits the options to reuse standard web development tooling. Communications between the main processes and renderer processes use IPC, which requires a main process roundtrip when communicating between two render processes.
If your app falls in the low or medium-complexity categories, congrats! Many of the headaches that arise in offline apps won’t apply to you. However, if your app requirements fall in the high complexity range, there is still hope!
When we consider issues in offline apps like performance degradation, roundtrip communication between render processes, and the overall developer experience, we need a specialized architecture:
The proposed architecture is built on the following pillars:
Let’s go through each of the modules in detail!
Note: parts of the stack are chosen purely due to personal preference and are interchangeable. For example, you can swap TypeScript for JavaScript, React for Vue, Redux for MobX, or npm packages for code sharing instead of Yarn workspaces. As long as the pillars mentioned above are respected, you have freedom of choice across the stack.
The shared module is responsible for the code and types shared by both the frontend and backend modules. It enables you to develop both modules as separate entities while still sharing the domain-relevant code and types.
Codesharing is achieved using Yarn workspaces, a simple alternative to publishing the module as an npm package, releasing, and versioning it.
Main features include:
The frontend module is responsible for all things UI. It contains the components and animations of our app but not the business logic. In production, Electron serves it from generated static files.
Main features include:
The backend module contains the backend codebase and the Electron setup code. The business logic and long-running operations, like image processing, will run in a separate Node.js process so that the UI doesn’t suffer from degraded performance.
Main features include:
The frontend and backend communicate using interprocess message passing with node-ipc
. The message passing allows for async
and event-based communication.
async
communication is best suited for short-lived operations. The frontend can wait until the backend processes the message to get the result right away.
Event-based communication is better suited for long-lived operations, like batch processing. As the task processes in the backend, it sends events that will modify the frontend’s app state in Redux. The backend can asynchronously complete long-running tasks and periodically update the progress displayed by the UI.
Main features include:
node-ipc
as the communication libraryElectron is a powerful tool, but like any other, you’ve got to use it the right way following all best practices so you don’t miss out on both security and speed.
Let’s look at some of the security and performance best practices that will help you build secured and performant applications with Electron. Let’s start with the security best practices:
Now let’s look at the performance best practices:
IPC in Electron is a critical component, that facilitates the communication between the application’s main and renderer processes. Let’s look at the types of inter-process communication in Electron.js:
Asynchronous IPC: This is a type of IPC that allows processes to send and receive messages without having to wait for the other process. It doesn’t block the sender from continuing its operations while it waits for a response. Instead, the sender can continue processing other tasks. It uses ipcRenderer.send
and ipcMain.on
for asynchronous messaging between the renderer and main processes.
Synchronous IPC: This is another type of IPC where the sender sends a message and waits for a response before continuing the next operation. In this type of communication, the sender is blocked until a response is received from the recipient, which ensures that certain operations are carried out in a specific sequence and that specific tasks are completed before the next one begins.
However, while it provides more control over the order of operations, it can potentially lead to performance issues, especially if the receiver takes a long time to respond or if there’s a high volume of synchronous requests. So use synchronous IPC carefully or only when needed.
Finally, below are some best practices I would recommend when working with inter-process communication:
Understanding the importance of scalability and modular design is important when designing an advanced Electron.js architecture because it ensures that the application remains adaptable and maintainable as the system grows. This type of architectural design approach not only increases the performance of the system but also makes future updates or upgrades easier. Module interactions are classified as follows:
Modular integration and interaction: This is the process of combining or integrating several modules or components of an application to perform as a cohesive entity. It focuses on how modules are designed and integrated, ensuring that the entire program functions effectively. This includes things like dependency resolution, modular interface design, and ensuring that data flows consistently between modules.
Inter-module communication: This refers to the processes and protocols that modules use to share information or signals with one another. This could include techniques like inter-process communication (IPC) for synchronous and asynchronous messaging. Inter-module communication enables modules to work together, share data, and trigger actions based on events or conditions in other modules.
With the knowledge of how modules interact with each other, let’s move forward to understanding how decoupling a system can allow for maintainability and scalability.
Decoupling to improve maintainability: The deliberate decoupling of modules is an essential component of advanced Electron.js architecture. This intentional separation guarantees that each module operates as a separate entity, minimizing interwoven dependencies. The following are some of the strategies to decouple a system to achieve maintainability:
Decoupling for scalability: As systems grow, their design must allow the easy integration of new features and modules without requiring major system overhauls. The following are some of the strategies to achieve it:
Electron is a great choice for building cross-platform desktop applications using different web technologies. Although Electron is easy to use in low-complexity apps, performance and developer experience limitations will surface as the complexity increases.
The proposed architecture aims to provide a sound conceptual foundation for high-complexity apps. Of course, it may need to be extended depending on the use case, but I’ve found that it serves as a good foundation for many types of apps.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Hey there, want to help make our blog better?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowBuild scalable admin dashboards with Filament and Laravel using Form Builder, Notifications, and Actions for clean, interactive panels.
Break down the parts of a URL and explore APIs for working with them in JavaScript, parsing them, building query strings, checking their validity, etc.
In this guide, explore lazy loading and error loading as two techniques for fetching data in React apps.
Deno is a popular JavaScript runtime, and it recently launched version 2.0 with several new features, bug fixes, and improvements […]
7 Replies to "Advanced Electron.js architecture"
Hello, great article and very insightful!
I’ve worked on a project which categorized as medium-complexity app, and I agree that while it may gets powerful on term of features, it would be more complex as more features come.
I have a question about the proposed architecture though. Where will the backend be hosted? Is it on the cloud? Or is it on another electron application, some kind of requirement to install before I can use the frontend? Or is it still in the same electron app? I’m not quite sure with what you mean by “…will run on another Node.js process…”
Thanks again for the article, love it!
Thanks for the kind words, Bagas!
In the proposed architecture, the backend runs (and is hosted) within the electron app. The backend process is forked from the electron main process, but is doesn’t have to be installed separately, the fork happens when the app starts. By node.js process I meant: https://nodejs.org/api/process.html
The separation between frontend and backend is only on the developer side. When the app is build and the executable generated, all the code is bundled together. Separating the frontend / backend into modules brings a better developer experience (regular web development tools can be applied), and enables to evolve each module independently (as they are loosely-coupled).
hwo do you establish the node_ipc channel between render process and child node process? do you message thru the main process?
The `node_ipc` channel enables bidirectional communication between the `UI` (renderer process) and the `backend` (render process in development, forked nodejs process in production), through a socket.
The main process initialises `node_ipc` to an available socket, and passes the socket reference to the `UI` and `backend`.
The `UI` receives the socket by an electron ipc message. The `backend` receives it differently depending on how it is instantiated. When instantiated as a render process (in development), it will receive it by an electron ipc message (same as the UI), and when instantiated as a forked process (in production), the socket will be passed as a parameter.
Thanks Alain, I really enjoyed the read! I’m teaching myself how to use electron and am interested in the security aspect of your architecture. I understand electron developers must be careful how they expose app capability to the internet (i.e. set nodeIntegration and contextIsolation appropriately so attacker cant execute native code on users machine). do you have any rules of thumb you follow when deciding to enable/disable nodeIntegration for your browserwindows?
Thanks for reaching out, Felix!
Regarding security, the possible surface of attack will differ greatly if you load remote scripts or local ones. In my examples, I assumed that the FE + BE scripts would be shipped with the app and not from a 3rd party server.
There is really good read about security on https://www.electronjs.org/docs/latest/tutorial/security/, which goes to a greater detail any comment could go 🙂
Cheers!
thank you for sharing this useful info.. author