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
.)
Total startup time | 426ms |
Total require() s time |
215ms |
Electron startup time | 211ms |
Electron binary size | 227MB |
Total startup time | 272ms |
Total require() s time |
41ms |
Electron startup time | 231ms |
Electron binary size | 238MB |
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% |
yarn
- Installs the dependency modules and applies the patches frompatches
.yarn use-snapshot
- Generates the V8 snapshot which contains the compilation and execution results of the dependency modules.yarn start
- Starts the application at improved speed because it uses the V8 snapshot. Quitting the app would write theevents.json
file created byperftrace
which can be opened up in https://ui.perfetto.dev to view the performance traces.yarn reset-snapshot
- Stops using the V8 snapshot.yarn start
- Starts the application but does not use the V8 snapshot. Quitting the app would write theevents.json
file created byperftrace
which can be opened up in https://ui.perfetto.dev to view the performance traces.
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
Let's break this down!
flowchart TD
yarn -- installs --> node_modules
node_modules -- manually copied over --> unsnapshottable.js
node_modules -- patch-package --> snapshottable-code["snapshottable code"]
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.
electron-snapshot-experiment/package.json
Line 16 in e95111b
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
.
When a newly added dependency needs to be patched, follow this guide from the official documentation.
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.
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"]
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
:
electron-snapshot-experiment/tools/create-v8-snapshot.js
Lines 10 to 24 in d683290
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:
electron-snapshot-experiment/tools/create-v8-snapshot.js
Lines 26 to 36 in d683290
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.
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
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:
electron-snapshot-experiment/v8-snapshots-util.js
Lines 6 to 29 in e95111b
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
:
electron-snapshot-experiment/v8-snapshots-util.js
Lines 31 to 38 in e95111b
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!
electron-snapshot-experiment/main.js
Lines 24 to 42 in e95111b
All the patches
and unsnapshottable.js
code belong to these categories:
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:
electron-snapshot-experiment/patches/glob+7.2.3.patch
Lines 1 to 12 in e95111b
and moved into:
electron-snapshot-experiment/unsnapshottable.js
Lines 17 to 23 in e95111b
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:
electron-snapshot-experiment/patches/ws+8.9.0.patch
Lines 208 to 229 in e95111b
and moved into:
electron-snapshot-experiment/unsnapshottable.js
Lines 145 to 150 in e95111b
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:
electron-snapshot-experiment/patches/nedb+1.8.0.patch
Lines 14 to 28 in e95111b
and moved into:
electron-snapshot-experiment/unsnapshottable.js
Lines 57 to 68 in e95111b
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:
electron-snapshot-experiment/patches/node-ipc+9.1.1.patch
Lines 47 to 59 in e95111b
and moved into:
electron-snapshot-experiment/unsnapshottable.js
Lines 152 to 158 in e95111b
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:
electron-snapshot-experiment/patches/ws+8.9.0.patch
Lines 67 to 97 in e95111b
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:
electron-snapshot-experiment/unsnapshottable.js
Lines 90 to 96 in e95111b