Skip to content

Speeding up Electron apps by using V8 snapshots in the main process

Notifications You must be signed in to change notification settings

RaisinTen/electron-snapshot-experiment

Repository files navigation

Electron V8 snapshot experiment

This experiment shows how you can use V8 snapshot to speed up the require()s for the main process of your Electron application by more than 80%!

(All measurements have been peformed on an x86_64 macOS running Sonoma 14.6.1.)

Before using V8 snapshot

Total startup time 426ms
Total require()s time 215ms
Electron startup time 211ms
Electron binary size 227MB

After using V8 snapshot

Total startup time 272ms
Total require()s time 41ms
Electron startup time 231ms
Electron binary size 238MB

Results

This has successfully removed 81% of the time spent running all the require()s causing an overall 36% performance improvement to the total startup time with minor changes to the Electron startup time and binary sizes!

Before After Difference % change
Total startup time 426ms 272ms 154ms 36%
Total require()s time 215ms 41ms 174ms 81%
Electron startup time 211ms 231ms -20ms -10%
Electron binary size 227MB 238MB -11MB -5%

Usage

  1. yarn - Installs the dependency modules and applies the patches from patches.
  2. yarn use-snapshot - Generates the V8 snapshot which contains the compilation and execution results of the dependency modules.
  3. yarn start - Starts the application at improved speed because it uses the V8 snapshot. Quitting the app would write the events.json file created by perftrace which can be opened up in https://ui.perfetto.dev to view the performance traces.
  4. yarn reset-snapshot - Stops using the V8 snapshot.
  5. yarn start - Starts the application but does not use the V8 snapshot. Quitting the app would write the events.json file created by perftrace which can be opened up in https://ui.perfetto.dev to view the performance traces.

Explanation

main.js is the code for a typical Electron application that require()s a bunch of JS dependencies, runs some setup code and then opens a window. Of the total 426ms startup time, the require()s alone take up 215ms, which is 50%! We can reduce this further by using V8 snapshot.

flowchart TD

subgraph Dependency installation
  yarn -- installs --> node_modules
  node_modules -- manually copied over --> unsnapshottable.js
  node_modules -- patch-package --> snapshottable-code["snapshottable code"]
end

subgraph Generating the snapshot
  snapshot.js -- require(...) --> snapshottable-code
  snapshot.js -- electron-link --> cache/snapshot.js
  cache/snapshot.js -- electron-mksnapshot --> v8_context_snapshot.x86_64.bin & snapshot_blob.bin
  v8_context_snapshot.x86_64.bin -- copied into --> electron-bundle["electron bundle"]
end

subgraph App using the snapshot
  electron -- loads --> v8_context_snapshot.x86_64.bin
  electron -- runs --> main.js -- runs --> v8-snapshots-util.js
  v8-snapshots-util.js -- monkey-patches --> require["require(...)"] -- load modules from --> v8_context_snapshot.x86_64.bin
  main.js -- hydrates the snapshot --> unsnapshottable.js
  main.js --> require
end
Loading

Let's break this down!

Dependency installation

flowchart TD

yarn -- installs --> node_modules
node_modules -- manually copied over --> unsnapshottable.js
node_modules -- patch-package --> snapshottable-code["snapshottable code"]
Loading

When yarn is run, it installs the dependencies of the project and also runs the postinstall script to patch the installed packages, so that those could be added to the V8 snapshot.

"postinstall": "patch-package"

The V8 snapshot is just a serialized heap of V8. During application startup, the V8 heap is deserialized from the snapshot, so that we have the V8 context populated with the same JS objects that were present during the snapshot creation. There is an important limitation to V8 snapshots: the snapshot can only capture V8's heap. Any interaction from V8 with the outside is off-limits when creating the snapshot. This includes usage of functionalities from the Node.js or Electron runtime that do not belong to the JS language - things like the require('http') builtin module, the global process object, etc. To include a dependency module into the V8 snapshot, we need to make sure that executing the module code doesn't require the presence of anything that is not already a part of the JS language.

Executing this code would require the presence of require() from Node.js and the https module object, so that it can be monkey-patched. This code cannot be a part of the V8 snapshot because neither of the two are a part of the JS language:

const https = require('https');
https.get = function (_url, _options, cb) {
  // ...
};

However, once our Electron app starts running, we would be able to run this code because by then, require() and the https module object would be available. This means that to make this script snapshottable, we would need to patch it, so that we can move the unsnapshottable code out of this script and run it during application startup after this script has been deserialized from the V8 snapshot.

When patch-package is run, it applies the patches from patches which removes the unsnapshottable code from the modules. The unsnapshottable code is then moved over to unsnapshottable.js which is run during application startup in main.js.

Patching new dependencies

When a newly added dependency needs to be patched, follow this guide from the official documentation.

Updating patched dependencies

When a patched dependency gets updated, the code changes from the existing dependency patch file, that are still relevant, needs to be applied manually along with any additional changes and a new patch file needs to be created by following this guide from the official documentation. Then the patch file corresponding to the previous dependency version can be deleted.

Generating the snapshot

flowchart TD

snapshot.js -- require(...) --> snapshottable-code["snapshottable code"]
snapshot.js -- electron-link --> cache/snapshot.js
cache/snapshot.js -- electron-mksnapshot --> v8_context_snapshot.x86_64.bin & snapshot_blob.bin
v8_context_snapshot.x86_64.bin -- copied into --> electron-bundle["electron bundle"]
Loading

The V8 snapshot is generated by running yarn use-snapshot.

It executes the electron-link module to generate the cache/snapshot.js file from snapshot.js:

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})

snapshot.js contains code that calls require() on all the dependency modules that can be a part of the V8 snapshot. electron-link starts from snapshot.js and traverses the entire require graph and replaces all the require calls to the builtin Node.js and Electron modules that cannot be a part of the V8 snapshot in each file with a function that will be called at runtime. The output, cache/snapshot.js, contains the code for all the modules reachable from the entry point.

Consider these file contents:

snapshot.js

require('a');

node_modules/a/index.js

const os = require('os');
exports.os = function () {  return os.version(); };
exports.tmpdir = function () {  return os.tmpdir(); };

Passing these through electron-link would produce this cache/snapshot.js:

// ...
function generateSnapshot () {
  // ...
  function customRequire (modulePath) {
    // Polyfill of Node.js' require() that is used in the V8 snapshot
    // to load the modules from the functions below.
  }
  // ...
    "./snapshot.js": function (exports, module, __filename, __dirname, require, define) {
      require("./node_modules/a/index.js");

    },
    "./node_modules/a/index.js": function (exports, module, __filename, __dirname, require, define) {
      let os;

      function get_os() {
        return os = os || require('os');
      }

      exports.os = function () {  return get_os().version(); };
      exports.tmpdir = function () {  return get_os().tmpdir(); };

    },
  // ...

  // Executes all the module contents and populates customRequire.cache with the exports objects.
  customRequire("./snapshot.js")
  return {
    customRequire,
    setGlobals: function (newGlobal, newProcess, newWindow, newDocument, newConsole, nodeRequire) {
      // Sets the globals in the V8 snapshot with the passed values.
    },
    // ...
  };
}
// ...

// Once the V8 heap gets deserialized from the V8 snapshot, this variable can be directly accessed in the entrypoint script.
var snapshotResult = generateSnapshot.call({})

// ...

Executing the contents of the cache/snapshot.js script will not depend on anything that is not already a part of the JS language, so the resulting V8 heap at the end of running this script can be snapshotted and it would contain the exports of all the dependency modules.

Then the electron-mksnapshot module is used to process the cache/snapshot.js script and generate the V8 snapshot:

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]

It creates the v8_context_snapshot.x86_64.bin and snapshot_blob.bin files, of which only v8_context_snapshot.x86_64.bin is required.

In tools/copy-v8-snapshot.js, the v8_context_snapshot.x86_64.bin file is copied into the electron bundle. During startup, Electron loads the V8 heap from the v8_context_snapshot.x86_64.bin V8 snapshot file.

App using the snapshot

flowchart TD

electron -- loads --> v8_context_snapshot.x86_64.bin
electron -- runs --> main.js -- runs --> v8-snapshots-util.js
v8-snapshots-util.js -- monkey-patches --> require["require(...)"] -- load modules from --> v8_context_snapshot.x86_64.bin
main.js -- hydrates the snapshot --> unsnapshottable.js
main.js --> require
Loading

The yarn start command is used to start the app.

During startup, Electron loads the v8_context_snapshot.x86_64.bin file and deserializes the V8 heap from it. Now, the snapshotResult variable which was defined in the cache/snapshot.js V8 snapshot script would be available in the current V8 context. Then when Electron starts executing the app code from main.js, the v8-snapshots-util.js script gets run at first.

The snapshotResult.customRequire.cache variable is prepopulated with a map of module paths and the exports objects of those modules. Since the snapshotResult variable would also be available to the v8-snapshots-util.js script, it monkey-patches the require() functionality, so that it is optimized to return the module exports object directly from the snapshotResult.customRequire.cache map for modules that belong to the V8 snapshot and using the default functionality of compiling and executing the modules from scratch for modules that do not belong to the V8 snapshot:

const Module = require('module')
const entryPointDirPath = __dirname;
// 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
}

Then v8-snapshots-util.js calls snapshotResult.setGlobals() to replace the polyfilled globals present in the V8 snapshot, like customRequire() for require(), with the actual ones that are available at runtime, so that when the exported code gets run it uses the actual globals instead of the limited polyfills defined in cache/snapshot.js:

snapshotResult.setGlobals(
global,
process,
undefined /* window */,
undefined /* document */,
console,
require
)

Now that require() has been configured to load dependency modules from the V8 snapshot, main.js runs unsnapshottable.js to execute the parts of those modules that had to be patched because those were not snapshottable.

Finally, the application code is ready to use the dependency modules from the V8 snapshot!

const semver = require('semver');
const circularJSON = require('circular-json');
const _ = require('lodash');
const async = require('async');
const i18n = require('i18next');
const jsonStorage = require('electron-json-storage');
const uuidV4 = require('uuid/v4');
const nedb = require('nedb');
const sentry = require('@sentry/node');
const sh = require('shelljs');
const sudo = require('sudo-prompt');
const SerializedError = require('serialised-error');
const initializeUpdater = require('@postman/app-updater').init;
const { WebSocketServer } = require('ws');
const { encryptAES, decryptAES } = require('@raisinten/aes-crypto-js');
const ipc = require('node-ipc');
const { Originator, Collectors } = require('@postman/app-logger');
const forge = require('node-forge');
const pem = require('@postman/pem');

Patches

All the patches and unsnapshottable.js code belong to these categories:

Exporting code that inherits from Node.js builtins

If a constructor is exported from a module that inherits from Node.js builtins like EventEmitter, it would not be possible to add it to the snapshot:

module.js

const util = require('util');
const EventEmitter = require('events');

function X() {
  // ...
}

util.inherits(X, EventEmitter);

module.exports = {
  X
};

This has been handled by moving the use of the Node.js builtins from the module code into unsnapshottable.js:

module.js

function X() {
  // ...
}

module.exports = {
  X
};

unsnapshottable.js

const util = require('util');
const EventEmitter = require('events');
const { X } = require('module');

util.inherits(X, EventEmitter);

Since require() uses a cache, modifications to the exported object are persisted across multiple require() calls to the same module. Hence, it is possible to make X inherit from EventEmitter dynamically at runtime when those Node.js builtins are available.

For example, the inheritance logic of the glob module has been patched in:

diff --git a/node_modules/glob/glob.js b/node_modules/glob/glob.js
index 37a4d7e..c6b7be1 100644
--- a/node_modules/glob/glob.js
+++ b/node_modules/glob/glob.js
@@ -113,7 +113,6 @@ glob.hasMagic = function (pattern, options_) {
}
glob.Glob = Glob
-inherits(Glob, EE)
function Glob (pattern, options, cb) {
if (typeof options === 'function') {
cb = options

and moved into:

// Make require('glob').Glob inherit from EventEmitter. This needs to be done
// here because EventEmitter is not a part of the default V8 context which is
// used to generate the V8 snapshot.
{
const glob = require('glob');
util.inherits(glob.Glob, EventEmitter);
}

Exporting classes that inherit from Node.js builtins

We use a similar trick for classes that inherit from Node.js builtins by using extends and super keywords:

module.js

const EventEmitter = require('events');

class X extends EventEmitter {
  constructor() {
    super();
    // ...
  }
  // ...
}

module.exports = {
  X
};

The module code can be added to the V8 snapshot with the following modifications:

module.js

const EventEmitter = require('events');

class X {
  constructor() {
    EventEmitter.call(this);
    // ...
  }
  // ...
}

module.exports = {
  X
};

unsnapshottable.js

const EventEmitter = require('events');
const { X } = require('module');

function extendClass(target, base) {
  Object.setPrototypeOf(target, base); // Static properties of base can now be accessed on target.
  Object.setPrototypeOf(target.prototype, base.prototype); // Prototypal properties of base can now be accessed on target.
}

extendClass(X, EventEmitter);

Use of extends have been substituted with a dynamic class inheritance logic in unsnapshottable.js and use of super has been replaced with a call to the base class constructor which also passes the this object.

For example, the inheritance logic of the node_modules/ws/lib/websocket-server.js module has been patched in:

diff --git a/node_modules/ws/lib/websocket-server.js b/node_modules/ws/lib/websocket-server.js
index bac30eb..89de725 100644
--- a/node_modules/ws/lib/websocket-server.js
+++ b/node_modules/ws/lib/websocket-server.js
@@ -26,7 +26,7 @@ const CLOSED = 2;
*
* @extends EventEmitter
*/
-class WebSocketServer extends EventEmitter {
+class WebSocketServer {
/**
* Create a `WebSocketServer` instance.
*
@@ -54,7 +54,7 @@ class WebSocketServer extends EventEmitter {
* @param {Function} [callback] A listener for the `listening` event
*/
constructor(options, callback) {
- super();
+ EventEmitter.call(this);
options = {
maxPayload: 100 * 1024 * 1024,

and moved into:

// Move the code for making WebSocket from 'ws/lib/websocket-server' inherit from
// EventEmitter here because EventEmitter is not a part of the V8 snapshot.
{
const WebSocketServer = require('./node_modules/ws/lib/websocket-server');
extendClass(WebSocketServer, EventEmitter);
}

Re-exporting Node.js builtins

Since Node.js builtins are not a part of the V8 snapshot, those cannot be re-exported from a module belonging to the V8 snapshot:

module.js

const fs = require('fs');

exports.f = function f() { /* ... */ };
exports.read = fs.read;
exports.write = fs.write;

This can be fixed by moving the code for re-exporting the Node.js builtins into unsnapshottable.js:

module.js

exports.f = function f() { /* ... */ };

unsnapshottable.js

const fs = require('fs');
const module = require('module');

module.read = fs.read;
module.write = fs.write;

For example, the exports of the node_modules/nedb/lib/storage.js module has been patched in:

diff --git a/node_modules/nedb/lib/storage.js b/node_modules/nedb/lib/storage.js
index 128f9cc..c405ad6 100755
--- a/node_modules/nedb/lib/storage.js
+++ b/node_modules/nedb/lib/storage.js
@@ -14,12 +14,6 @@ var fs = require('fs')
, storage = {}
;
-storage.exists = fs.exists;
-storage.rename = fs.rename;
-storage.writeFile = fs.writeFile;
-storage.unlink = fs.unlink;
-storage.appendFile = fs.appendFile;
-storage.readFile = fs.readFile;
storage.mkdirp = mkdirp;

and moved into:

// Move the code for attaching the fs module methods from 'nedb/lib/storage' to
// here because the fs module is not available in the V8 snapshot.
{
const fs = require('node:fs');
const storage = require('nedb/lib/storage');
storage.exists = fs.exists;
storage.rename = fs.rename;
storage.writeFile = fs.writeFile;
storage.unlink = fs.unlink;
storage.appendFile = fs.appendFile;
storage.readFile = fs.readFile;
}

Exporting objects of classes that depend on Node.js builtins

It is not possible to create an object of a class if the construction logic requires Node.js builtins to be present as those are not a part of the V8 snapshot:

module.js

class X {
  constructor() {
    this.x = process.getActiveResourcesInfo();
  }
};

module.exports = new X();

This can be fixed by just exporting the class from the module and then later on in unsnapshottable.js, replacing the exports object with an instance of the class:

module.js

class X {
  constructor() {
    this.x = process.getActiveResourcesInfo();
  }
};

module.exports = X;

unsnapshottable.js

const X = require('module');
require.cache[require.resolve('x')] = new X();

For example, the exports of the node_modules/node-ipc/node-ipc.js module has been patched in:

diff --git a/node_modules/node-ipc/node-ipc.js b/node_modules/node-ipc/node-ipc.js
index 1c63360..bf84820 100644
--- a/node_modules/node-ipc/node-ipc.js
+++ b/node_modules/node-ipc/node-ipc.js
@@ -18,4 +18,7 @@ class IPCModule extends IPC{
}
}
-module.exports=new IPCModule;
+// This returns the IPCModule class instead of the IPCModule instance, so that
+// this can be snapshotted. In unsnapshottable.js, this module gets
+// monkey-patched to return an IPCModule instance instead of the class.
+module.exports=IPCModule;

and moved into:

// Monkey-patch the 'node-ipc' module to export an IPCModule instance instead of
// the IPCModule class because that is what the original module does. It has
// been patched to return the class, so that it can be snapshotted.
{
const IPCModule = require('node-ipc');
require.cache[require.resolve('node-ipc')] = new IPCModule();
}

Uses of Buffer and TypedArray in the global scope

Since Buffer is a Node.js builtin and TypedArrays are not supported in V8 snapshots (since the backing store may be allocated outside of V8), those cannot be used in the global scope of snapshotted modules:

module.js

const b = Buffer.from([1, 2, 3]);

function f() {
  // Do something with b.
}

function g() {
  // Do something with b.
}

exports.f = f;
exports.g = g;

This can be fixed by creating the Buffer / TypedArray instances lazily only when needed:

module.js

let b;
function initB() {
  if (b) return;
  b = Buffer.from([1, 2, 3]);
}

function f() {
  initB();
  // Do something with b.
}

function g() {
  initB();
  // Do something with b.
}

exports.f = f;
exports.g = g;

For example, the buffer creation in the node_modules/ws/lib/permessage-deflate.js module has been modified into a lazy initialization:

diff --git a/node_modules/ws/lib/permessage-deflate.js b/node_modules/ws/lib/permessage-deflate.js
index 94603c9..99b4e10 100644
--- a/node_modules/ws/lib/permessage-deflate.js
+++ b/node_modules/ws/lib/permessage-deflate.js
@@ -6,7 +6,13 @@ const bufferUtil = require('./buffer-util');
const Limiter = require('./limiter');
const { kStatusCode } = require('./constants');
-const TRAILER = Buffer.from([0x00, 0x00, 0xff, 0xff]);
+// Buffer is not a part of the V8 snapshot, so instead of calling Buffer.alloc()
+// directly, use a function that calls that instead.
+let TRAILER;
+function initTrailer() {
+ if (TRAILER) return;
+ TRAILER = Buffer.from([0x00, 0x00, 0xff, 0xff]);
+}
const kPerMessageDeflate = Symbol('permessage-deflate');
const kTotalLength = Symbol('total-length');
const kCallback = Symbol('callback');
@@ -359,7 +365,10 @@ class PerMessageDeflate {
this._inflate[kCallback] = callback;
this._inflate.write(data);
- if (fin) this._inflate.write(TRAILER);
+ if (fin) {
+ initTrailer();
+ this._inflate.write(TRAILER);
+ }
this._inflate.flush(() => {
const err = this._inflate[kError];

Modules that uses or monkey-patches Node.js builtins during module execution

Since Node.js builtins are not a part of the V8 snapshot, those cannot be monkey-patched or used during module execution if it needs to belong to the V8 snapshot:

module.js

// snapshottable code

const fs = require('fs');
fs.read = function () {
  // ...
};

delete process.env.OLDPWD;

process.on('exit', (code) => {
  console.log('Process exit event with code: ', code);
});

This can be fixed by simply moving the problematic code into unsnapshottable.js:

module.js

// snapshottable code

unsnapshottable.js

const fs = require('fs');
fs.read = function () {
  // ...
};

delete process.env.OLDPWD;

process.on('exit', (code) => {
  console.log('Process exit event with code: ', code);
});

For example, the code in the agent-base module that monkey-patched the Node.js builtins has been removed in:

and moved to unsnapshottable.js in:

// Make require('agent-base') inherit from EventEmitter. This needs to be done
// here because EventEmitter is not a part of the default V8 context which is
// used to generate the V8 snapshot. Also, runs 'agent-base/patch-core'.
{
require('agent-base/patch-core')
util.inherits(Agent, EventEmitter);
}

About

Speeding up Electron apps by using V8 snapshots in the main process

Resources

Stars

Watchers

Forks

Releases

No releases published

Sponsor this project

 

Packages

No packages published