How to make your Electron app launch 1,000ms faster

Takuya Matsuyama - Aug 11 '20 - - Dev Community

Hi, I'm Takuya, an indie developer building a Markdown note-taking app called Inkdrop.
This app is built on top of Electron, a framework that allows you to build a cross-platform desktop app based on NodeJS and Chromium (browser).
It is basically a great framework because you can build desktop apps without learning native frameworks or languages but with JavaScript, HTML and CSS. If you are a web developer, you can build desktop apps quickly.
On the other side, people often mention about the Electron's downside - the app startup time tends to be slow.
My app encountered this issue as well, as I've got complaints about the slow launch speed from some users.
Yeah, the slow startup is so stressful.
But I'm extremely happy that I accomplished to solve it.
The app's TTI (Time to Interactive) has been boosted from 4 seconds to 3 seconds on my mac.
I'd say "1,000msecs faster" instead of just "1sec faster" because it is a significant improvement and I've worked very hard for it!
Take a look at the following comparison screencast:

Launch speed comparison

You can feel it's quite faster than the previous version.
As you can see above, the app main window shows up a little quicker, and loading the app bundle in the browser window finishes also quickly.
It's currently in beta and the users told me they are happy with the improved launch speed.
I can't wait to roll it out officially.

I guess there are a lot of developers struggling to solve the same issue, so I'd like to share how I've done it.
Let's boost your Electron app!

TL;DR

  • Loading JavaScript is too slow
  • Don't call require() until you need (300ms improved)
  • Use V8 snapshots (700ms improved)

Loading JavaScript is too slow

So, why do Electron apps tend to start up slowly?
The biggest bottleneck in app launch is obviously the process to load JavaScript.
You can inspect how your app bundle is being loaded in Developer Tools' performance analyzer.

Press Cmd-E or the red dot record button to start capturing runtime performance, then reload the app.
And you will see a timeline something like this:

Performance analysis

You should see requiring modules is taking long time in the timeline.
How long it takes depends on how many modules/libraries your app depends.

In my case, my app has an enormous number of dependencies in order to provide its plug-in capability, extensible markdown editor and renderer, and so on.
It seems to be difficult to drop those dependencies for the sake of the launch speed.

If you have a new project, you have to carefully choose libraries for performance.
Less dependencies are always better.

Don't call require() until you need

The first thing you can do to avoid the big loading time is to defer calling require() for your dependencies until they're necessary.

My app main window now shows up a little bit faster than the old version.
That's because it was loading jsdom in the main process on launch.
I added it to parse HTML but found it is a huge library and it requires several hundreds milliseconds to load.

There are several ways to solve such issue.

1. Use a lighter alternative

If you found it heavy to load, you can use a small alternative library if exists.
It turned out that I don't need jsdom to parse HTML because there is DOMParser in Web API. You can parse HTML with it like so:

const dom = new DOMParser().parseFromString(html, 'text/html')
Enter fullscreen mode Exit fullscreen mode

2. Avoid requiring on the evaluating-time

Instead of requiring the library on evaluating your code:

import { JSDOM } from 'jsdom'

export function parseHTML(html) {
  const dom = new JSDOM(html);
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Defer requiring it until you actually need the library:

var jsdom = null

function get_jsdom() {
  if (jsdom === null) {
    jsdom = require('jsdom')
  }
  return jsdom
}

export function parseHTML(html) {
  const { JSDOM } = get_jsdom()
  const dom = new JSDOM(html);
  // ...
}
Enter fullscreen mode Exit fullscreen mode

It would improve your startup time without dropping the dependency.
Note that you must exclude those dependencies from your app bundle if you are using a module bundler like Webpack.

Use V8 snapshots

Now my app launches 200-300ms faster, but still loads slow in the renderer process.
The most dependencies can't be deferred to be required as they are used immediately.

Chromium has to read and evaluate your JS and modules which needs long time than you'd imagine even when from local filesystem (1-2 secs in my app).
Most native apps don't need to do that because they are already in binary code and your OS can run them without translating into a machine language.

Chromium's JavaScript engine is v8.
And there is a technique in v8 to speed things up: V8 snapshots.
V8 snapshots allow Electron apps to execute some arbitrary JavaScript code and output a binary file containing a serialized heap with all the data that is left in memory after running a GC at the end of the provided script.

Atom Editor has utilized V8 snapshots and improved startup time 3 years ago:

Atom team accomplished to boost the startup time around 500ms on their machine.
Looks promising.

How V8 snapshots work

Let me get straight to the point - It worked great for my app as well.
For example, loading remark-parse has been drastically shrunk.

Without v8 snapshots:

Loading remark-parse without v8 snapshots

With v8 snapshots:

Loading remark-parse with v8 snapshots enabled

Cool!!!

I could improve the loading time on evaluating browser-main.js from:

Loading browser-main.js without v8 snapshots
To:

Loading browser-main.js with v8 snapshots

Here is a screencast of loading Preferences window, illustrating how much v8 snapshots improved loading speed of the app bundle:

Preferences window

But how do you load modules from V8 snapshots?
In an Electron app with your custom V8 snapshots, you get snapshotResult variable in global scope.
It contains pre-loaded cache data of JavaScript that are already executed beforehand as following:

snapshotResult

You can use those modules without calling require().
That's why V8 snapshots work very fast.

In the next section, I'll explain how to create your custom V8 snapshots.

How to create custom V8 snapshots

You have to do the following steps:

  1. Install tools
  2. Preprocess the JavaScript source file with electron-link
  3. Create the v8 snapshots with mksnapshot
  4. Load the snapshots in Electron

I created a simple example project for this tutorial. Check out my repository here:

Install tools

The following packages are needed:

package description
electron Runtime
electron-link Preprocess the JavaScript source files
electron-mksnapshot Download the mksnapshot binaries

mksnapshot is a tool to create V8 snapshots from your preprocessed JavaScript file with electron-link.
electron-mksnapshot helps download the compatible mksnapshot binaries for Electron.
But if you are using old version of Electron, you have to set ELECTRON_CUSTOM_VERSION environment variable to your Electron version:

# Install mksnapshot for Electron v8.3.0
ELECTRON_CUSTOM_VERSION=8.3.0 npm install
Enter fullscreen mode Exit fullscreen mode

Downloading the binaries would take a long time. You can use an Electron mirror by setting ELECTRON_MIRROR environment variables as following:

# Electron mirror for China
ELECTRON_MIRROR="https://npm.taobao.org/mirrors/electron/"
Enter fullscreen mode Exit fullscreen mode

Preprocess the JavaScript source file with electron-link

electron-link helps you generate a JavaScript file which can be snapshotted.
Why you need it is that you can't require some modules like NodeJS built-in modules and native modules in a V8 context.
If you have a simple app, you can pass the entry point of your app.
In my case, my app was too complicated to generate a snapshot-able file.
So, I decided to create another JS file for generating the snapshots which just requires some libraries as following:

// snapshot.js
require('react')
require('react-dom')
// ...require more libraries
Enter fullscreen mode Exit fullscreen mode

Then, save it as snapshot.js in your project directory.
Create the following script that passes the JS file into electron-link:

const vm = require('vm')
const path = require('path')
const fs = require('fs')
const electronLink = require('electron-link')

const excludedModules = {}

async function main () {
  const baseDirPath = path.resolve(__dirname, '..')

  console.log('Creating a linked script..')
  const result = await electronLink({
    baseDirPath: baseDirPath,
    mainPath: `${baseDirPath}/snapshot.js`,
    cachePath: `${baseDirPath}/cache`,
    shouldExcludeModule: (modulePath) => excludedModules.hasOwnProperty(modulePath)
  })

  const snapshotScriptPath = `${baseDirPath}/cache/snapshot.js`
  fs.writeFileSync(snapshotScriptPath, result.snapshotScript)

  // Verify if we will be able to use this in `mksnapshot`
  vm.runInNewContext(result.snapshotScript, undefined, {filename: snapshotScriptPath, displayErrors: true})
}

main().catch(err => console.error(err))
Enter fullscreen mode Exit fullscreen mode

It will output a snapshotable script to <PROJECT_PATH>/cache/snapshot.js.
This JS file derived from electron-link contains the libraries directly, just like a bundle that webpack generates.
In the output, the forbidden modules (i.e. path) are deferred to be required so that they are not loaded in a v8 context (See electron-link's document for more detail.

Create the v8 snapshots with mksnapshot

Now we've got a snapshotable script to generate the V8 snapshots.
Run the below script to do so:

const outputBlobPath = baseDirPath
console.log(`Generating startup blob in "${outputBlobPath}"`)
childProcess.execFileSync(
  path.resolve(
    __dirname,
    '..',
    'node_modules',
    '.bin',
    'mksnapshot' + (process.platform === 'win32' ? '.cmd' : '')
  ),
  [snapshotScriptPath, '--output_dir', outputBlobPath]
)
Enter fullscreen mode Exit fullscreen mode

Check out the entire script here in the example repository.

Finally, you will get v8_context_snapshot.bin file in your project directory.

Load the snapshots in Electron

Let's load your V8 snapshots in your Electron app.
Electron has a default V8 snapshot file in its binary.
You have to overwrite it with yours.
Here is the path to the V8 snapshots in Electron:

  • macOS: node_modules/electron/dist/Electron.app/Contents/Frameworks/Electron Framework.framework/Versions/A/Resources/
  • Windows/Linux: node_modules/electron/dist/

You can copy your v8_context_snapshot.bin to there.
Here is the script to copy the file.
Then, start your app and you should get snapshotResult variable in global context.
Type snapshotResult in the console to check if it exists.

Now, you've got the custom snapshots loaded in your Electron app.
How to load dependency libraries from them?

You have to override the default require function as following:

const path = require('path')

console.log('snapshotResult:', snapshotResult)
if (typeof snapshotResult !== 'undefined') {
  console.log('snapshotResult available!', snapshotResult)

  const Module = require('module')
  const entryPointDirPath = path.resolve(
    global.require.resolve('react'),
    '..',
    '..',
    '..'
  )
  console.log('entryPointDirPath:', entryPointDirPath)

  Module.prototype.require = function (module) {
    const absoluteFilePath = Module._resolveFilename(module, this, false)
    let relativeFilePath = path.relative(entryPointDirPath, absoluteFilePath)
    if (!relativeFilePath.startsWith('./')) {
      relativeFilePath = `./${relativeFilePath}`
    }
    if (process.platform === 'win32') {
      relativeFilePath = relativeFilePath.replace(/\\/g, '/')
    }
    let cachedModule = snapshotResult.customRequire.cache[relativeFilePath]
    if (snapshotResult.customRequire.cache[relativeFilePath]) {
      console.log('Snapshot cache hit:', relativeFilePath)
    }
    if (!cachedModule) {
      console.log('Uncached module:', module, relativeFilePath)
      cachedModule = { exports: Module._load(module, this, false) }
      snapshotResult.customRequire.cache[relativeFilePath] = cachedModule
    }
    return cachedModule.exports
  }

  snapshotResult.setGlobals(
    global,
    process,
    window,
    document,
    console,
    global.require
  )
}
Enter fullscreen mode Exit fullscreen mode

Note that you must run it before loading the libraries.
You should see outputs like "Snapshot cache hit: react" in the developer console if it works properly.
In the example project, you should see the result something like:

Example result

Congrats! You've got your app's dependencies loaded from the V8 snapshots.

Eagerly constructing your app instance

Not only loading the dependencies from the cache, you can also use snapshots to construct your app instance like Atom does.
Some of the app construction tasks would be static and can be snapshotted, even though other tasks like reading the user's configuration are dynamic.
By pre-executing those initialization tasks using the snapshots, the launch speed can be improved further.
But that depends on your codebase.
For example, you can pre-construct React components in the snapshots.


That’s it! Hope it is helpful for your app development. Thank you for reading this.

I'm preparing to roll out the new version of Inkdrop with this improvement.
Hope you love it!

Inkdrop v5

See also

Thank you for all your support!

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .