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:
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:
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')
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);
// ...
}
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);
// ...
}
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:
With v8 snapshots:
Cool!!!
I could improve the loading time on evaluating browser-main.js
from:
Here is a screencast of loading Preferences window, illustrating how much v8 snapshots improved loading speed of the app bundle:
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:
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:
- Install tools
- Preprocess the JavaScript source file with
electron-link
- Create the v8 snapshots with
mksnapshot
- Load the snapshots in Electron
I created a simple example project for this tutorial. Check out my repository here:
- inkdropapp/electron-v8snapshots-example: An example for using custom v8 snapshots in an Electron app
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
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/"
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
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))
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]
)
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
)
}
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:
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!
See also
- How I Kept My Solo Project Going Over 3 Years
- Get A Slow Tempo — Towards Becoming A Long-Running Product
- How I’ve Attracted The First 500 Paid Users For My SaaS That Costs $5/mo
Thank you for all your support!
- Inkdrop Website: https://www.inkdrop.app/
- Send feedback: https://forum.inkdrop.app/
- Contact us: contact@inkdrop.app
- Twitter: https://twitter.com/inkdrop_app
- Instagram: https://www.instagram.com/craftzdog/
Top comments (3)
Thanks!
This new alternative can interest you: github.com/tauri-apps/tauri