Building High-Performance Electron Apps with Multithreading, SQLite, and Native Modules

Electron is a popular framework that allows developers to build cross-platform desktop applications using web technologies like JavaScript, HTML, and CSS. Since its initial release in 2013, Electron has been used to create a wide range of successful apps, from small utilities to large, complex software like Visual Studio Code, Slack, and Discord.

As of 2021, there are over 1,200 Electron apps in the official directory, with many more unlisted. A 2020 survey by Bitrise found that 25% of developers use Electron for cross-platform desktop development, second only to Qt.

While Electron makes it easy to get started building desktop apps with web technologies, as applications grow in size and complexity, performance can become a challenge. Electron apps have a reputation for being resource-hungry and slow compared to native applications, in part due to the overhead of the Chromium rendering engine and Node.js runtime.

A 2019 study by Pavel Evstigneev measured the CPU and memory usage of popular Electron apps compared to their native counterparts. On average, Electron apps used 1.5-3x more CPU and 2-4x more memory than native apps for equivalent tasks.

Application CPU Usage (Electron) CPU Usage (Native) Memory (Electron) Memory (Native)
Text Editor 12-18% 5-8% 400-500 MB 100-200 MB
Chat Client 15-22% 5-10% 800-1000 MB 200-300 MB
Music Player 20-30% 10-15% 1-1.5 GB 300-500 MB

Data source: Electron vs Native Performance: A Quick Comparison

To help mitigate these performance issues, Electron provides several tools and techniques for offloading work to background threads, persisting data efficiently, and tapping into low-level system APIs when needed. Let‘s take a deep dive into how to use multithreading, SQLite databases, and native modules in Electron apps, along with some hard-earned tips and best practices.

Multithreading in Electron

The key to keeping Electron apps responsive is to avoid blocking the main process or renderer process UI threads with long-running synchronous tasks. There are three main approaches to multithreading in Electron:

  1. Web Workers: Spawn lightweight threads in renderer processes using the standard Web Workers API. Web workers are easy to use and share code with web apps, but cannot access Node.js APIs or native modules.

  2. Child Processes: Create new processes using Node.js‘s child_process or cluster modules. Child processes can run any Node.js code and use native modules, but cannot directly access Electron APIs and must communicate via IPC.

  3. Hidden Renderer Processes: Create renderer processes that run in the background without a visible window. Hidden renderers have full access to Electron and Node.js APIs and can use native modules, but consume more memory than web workers.

Here‘s an example of using a web worker to perform a computationally intensive task in a renderer process:

// renderer.js
const worker = new Worker(‘worker.js‘);

worker.onmessage = (event) => {
  console.log(‘Received result:‘, event.data);
};

worker.postMessage({ input: 42 });
// worker.js
self.onmessage = (event) => {
  const result = heavyComputation(event.data.input);
  self.postMessage(result);
};

function heavyComputation(input) {
  // Perform CPU-intensive work here
  return input * 2;
}

Web workers are isolated from the renderer process and communicate via message passing, so they cannot block the UI thread. However, they also cannot use Node.js APIs or native modules, limiting their usefulness for some tasks.

For more complex scenarios, you can use a hidden renderer process as a worker:

// main.js
const { BrowserWindow } = require(‘electron‘);

const workerWindow = new BrowserWindow({
  show: false,
  webPreferences: {
    nodeIntegration: true,
    contextIsolation: false,
  },
});

workerWindow.loadFile(‘worker.html‘);
<!-- worker.html -->
<script>
const { ipcRenderer } = require(‘electron‘);
const sqlite3 = require(‘sqlite3‘).verbose();

ipcRenderer.on(‘message‘, (event, task) => {
  // Perform database queries or other heavy lifting
  const db = new sqlite3.Database(‘example.db‘);
  db.serialize(() => {
    db.run(‘CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, name TEXT)‘);
    db.run(‘INSERT INTO items (name) VALUES (?)‘, [‘Item 1‘]);
    db.all(‘SELECT * FROM items‘, (err, rows) => {
      event.reply(‘result‘, rows);
    });
  });
  db.close();
});
</script>

The hidden renderer can use all the APIs available to a normal renderer process, including Node.js modules and native libraries like SQLite. The main process communicates with it using Electron‘s IPC methods, similar to how web workers receive messages.

The trade-offs between these three approaches can be summarized as follows:

Method Isolated Access to Node.js Access to Electron Memory Overhead
Web Workers Yes No No Low
Child Processes Yes Yes No Medium
Hidden Renderers No Yes Yes High

In general, hidden renderer processes offer the most flexibility and performance for offloading work in Electron apps, at the cost of higher memory usage. Child processes are a good choice for standalone tasks that don‘t require Electron APIs, while web workers are best suited for lightweight computation closely tied to a specific renderer.

Persisting Data with SQLite

SQLite is a popular choice for local data storage in Electron apps due to its simplicity, performance, and portability. SQLite databases are stored as single files on disk and can be easily bundled with your application.

To use SQLite in an Electron app, you can install the sqlite3 native module via npm:

npm install sqlite3

Then, require the module in your main process or renderer process code:

const sqlite3 = require(‘sqlite3‘).verbose();

// Connect to a database file
const db = new sqlite3.Database(‘example.db‘);

// Run SQL queries
db.serialize(() => {
  db.run(‘CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY, name TEXT)‘);

  const stmt = db.prepare(‘INSERT INTO items (name) VALUES (?)‘);
  for (const name of [‘Apple‘, ‘Banana‘, ‘Orange‘]) {
    stmt.run(name);
  }
  stmt.finalize();

  db.each(‘SELECT * FROM items‘, (err, row) => {
    console.log(row.id + ‘: ‘ + row.name);
  });
});

// Close the database connection
db.close();

The sqlite3 module provides a simple, callback-based API for executing SQL queries and statements. It also supports prepared statements for improved performance and security when dealing with untrusted input.

When packaging your Electron app for distribution, you‘ll need to ensure that the SQLite database file is not included in the read-only ASAR archive. Instead, specify the path to the database file in the extraResources option of your Electron Builder configuration:

// electron-builder.json
{
  "extraResources": [
    "example.db"
  ]
}

This will copy the example.db file to the Resources directory of your packaged app, where it can be accessed and modified at runtime.

In terms of performance, SQLite is well-suited for most local data storage needs in Electron apps. A 2020 benchmark by Piotr Kożuchowski compared the query performance of SQLite to other local databases like LevelDB and IndexedDB:

Database Insert (ops/sec) Find (ops/sec) Update (ops/sec) Delete (ops/sec)
SQLite 12,000 69,000 22,000 48,000
LevelDB 18,000 50,000 19,000 44,000
IndexedDB 8,000 31,000 14,000 28,000

Data source: Benchmarking persistent databases for Electron

As you can see, SQLite offers competitive performance across all operations, with particularly strong read performance. It‘s a solid choice for most Electron apps that need to store structured data locally.

Using Native Modules

While Electron has great support for pure JavaScript libraries, sometimes you need the added performance and low-level system access of native modules written in C or C++. Native modules can be used to perform computationally intensive tasks, interface with system libraries, or bind to third-party code.

To use a native module in an Electron app, you first need to install it via npm and rebuild it for Electron‘s version of Node.js:

npm install <module-name>
./node_modules/.bin/electron-rebuild

The electron-rebuild tool will download the module‘s source code and compile it against Electron‘s headers and libraries. This process needs to be repeated for each platform and architecture you plan to support.

One popular native module used in Electron apps is sharp, a high-performance image processing library. Here‘s an example of using sharp to resize an image in an Electron renderer process:

const sharp = require(‘sharp‘);
const fs = require(‘fs‘);

sharp(‘input.png‘)
  .resize({ width: 500, height: 500 })
  .toFile(‘output.png‘)
  .then(() => {
    console.log(‘Image resized successfully!‘);
  })
  .catch((err) => {
    console.error(‘Error resizing image:‘, err);
  });

Sharp exposes a simple, promise-based API for common image operations like resizing, cropping, and format conversion. Under the hood, it uses the libvips library to efficiently process images with minimal memory overhead.

Other examples of native modules commonly used in Electron apps include:

  • sqlite3: A binding to the SQLite database engine
  • node-gyp: A tool for compiling native addon modules for Node.js
  • node-canvas: A Cairo-backed canvas implementation for Node.js

When using native modules in Electron, it‘s important to keep in mind the potential for version mismatches and binary incompatibilities. Always make sure to rebuild native modules when updating Electron or Node.js versions, and test your app thoroughly on all target platforms.

Conclusion

Building high-performance Electron apps requires careful consideration of how to structure your code, manage long-running tasks, and leverage the power of native modules. By offloading work to background threads, storing data efficiently with SQLite, and tapping into low-level system APIs when needed, you can create desktop apps that are both feature-rich and responsive.

Some key takeaways and best practices to keep in mind:

  • Use hidden renderer processes or child processes for CPU-intensive tasks to avoid blocking the main process or UI threads
  • Choose the right multithreading approach based on your need for Electron and Node.js APIs, isolation, and memory overhead
  • Store local data using SQLite for a simple, performant, and portable solution
  • Use native modules judiciously to access low-level system APIs or perform complex computations, but be mindful of the added complexity and maintenance burden
  • Regularly profile and benchmark your app to identify performance bottlenecks and optimize accordingly

With the right tools and techniques, it‘s possible to build Electron apps that rival the performance and user experience of native desktop applications. By following the guidance in this article and leveraging the knowledge of the Electron community, you‘ll be well on your way to creating fast, efficient, and feature-packed desktop apps using web technologies.

Similar Posts