- Published on
Improving Node.js loader performance
- Authors
- Name
- Yagiz Nizipli
- @yagiznizipli
Node.js supports 2 different modules. EcmaScript and CommonJS modules. ES modules are the official standard for modules in JavaScript and they are supported by all modern browsers. CommonJS modules are the modules that Node.js uses by default. They are not supported by browsers and they are not the official standard. However, they are still widely used.
How does Node.js load the entry point?
In order to differentiate which loader to use, Node.js depends on several factors. The most important one is the file extension. If the file extension is .mjs
, Node.js will use the ES module loader. If the file extension is .cjs
, Node.js will use the CommonJS module loader. If the file extension is .js
, Node.js will use the CommonJS module loader if the package.json
file has "type": "commonjs"
field (or simply doesn't have the type
field). If the package.json
file has "type": "module"
field, Node.js will use the ES module loader.
This decision is made in lib/internal/modules/run_main.js
file. You can see a simplified version of the code below:
const { readPackageScope } = require('internal/modules/package_json_reader')
function shouldUseESMLoader(mainPath) {
// Determine the module format of the entry point.
if (mainPath && mainPath.endsWith('.mjs')) {
return true
}
if (!mainPath || mainPath.endsWith('.cjs')) {
return false
}
const pkg = readPackageScope(mainPath)
switch (pkg.data?.type) {
case 'module':
return true
case 'commonjs':
return false
default: {
// No package.json or no `type` field.
return false
}
}
}
readPackageScope
traverses the directory tree upwards until it finds a package.json
file. Prior to the optimizations done on this post, readPackageScope
calls an internal version of fs.readFileSync
until it finds a package.json
file. This synchronous call makes a filesystem operation and communicates with Node.js C++ layer. This operation has performance bottlenecks depending on the value/type it returns because of the cost of serialization/deserialization of data. This is why we want to avoid calling readPackage
a.k.a. fs.readFileSync
inside readPackageScope
as much as possible.
How Node.js parses package.json
?
By default, readPackage
calls an internal version fs.readFileSync
to read the package.json
file. This synchronous call returns a string from Node.js C++ layer, which later gets parsed using V8's JSON.parse()
method. Depending on the validity of this JSON, Node.js checks and creates an object that's required for the remaining of the loaders to perform. These fields are pkg.name
, pkg.main
, pkg.exports
, pkg.imports
and pkg.type
. If the JSON has faulty syntax, Node.js will throw an error and exit the process.
The output of this function is later cached at an internal Map
to avoid calling readPackageScope
again for the same path. This cache is stored for the rest of the process lifetime.
Usage of package.json
fields and the reader
Before we dive into what optimizations we can do, let's see how Node.js uses these fields. The common use cases in Node.js codebase for parsing and re-using package.json
fields are:
pkg.exports
andpkg.imports
are used to resolve different modules according to your input.pkg.main
is used to resolve the entry point of the application.pkg.type
is used to resolve the module format of the file.pkg.name
is used if there is a self referencing require/import.
Additionally, Node.js supports an experimental version of Subresource Integrity
checkwhich uses the result of this package.json to validate the integrity of the file.
The most important usage is that, for every require/import
call, Node.js needs to know the module format of the file. For example, if the user require's a NPM module that uses ESM on a CommonJS (CJS) application, Node.js will need to parse the package.json
file of that module and throw an error if the NPM package is ESM.
Because of all of these calls and usages across ESM and CJS loaders, package.json
reader is one of the most important parts of the Node.js loader implementation.
Optimizations
Optimizing caching layer
In order to optimize the package.json
reader performance, I first moved the caching layer to the C++ side to make the implementation be closer to the filesystem call as much as possible. This decision forced to parse the JSON file in C++. At this point, I had 2 options:
- Use V8's
v8::JSON::Parse()
method which takes av8::String
as an input and returns av8::Value
as an output. - Use
simdjson
library to parse the JSON file.
Since the filesystem returns a string, converting that string into a v8::String
just to retrieve the keys and values as a std::string
didn't make sense. Therefore, I added simdjson
as a dependency to Node.js and used it to parse the JSON file. This change enabled us to parse the JSON file in C++ and extract and return only the necessary fields to the JavaScript side, reducing the size of the input that needs to be serialized/deserialized.
Avoiding serialization cost
In order to avoid returning unnecessary large objects, I changed the signature of the readPackage
function to return only the necessary fields. This change simplified the shouldUseESMLoader
as follows:
function shouldUseESMLoader(mainPath) {
// Determine the module format of the entry point.
if (mainPath && mainPath.endsWith('.mjs')) {
return true
}
if (!mainPath || mainPath.endsWith('.cjs')) {
return false
}
const response = getNearestParentPackageJSONType(mainPath)
// No package.json or no `type` field.
if (response === undefined || response[0] === 'none') {
return false
}
const { 0: type, 1: filePath, 2: rawContent } = response
checkPackageJSONIntegrity(filePath, rawContent)
return type === 'module'
}
Moving the caching layer to C++ enabled us to expose micro-functions that returns enums (integers) instead of strings to get a type of a package.json
file.
Reducing C++ calls to 1 to 1
On CommonJS, readPackageConfig
is implemented on the ESM loader under getPackageScopeConfig
function. This function made a lot of C++ calls in order to resolve and retrieve the applicable package.json
file. The implementation was as follows:
function getPackageScopeConfig(resolved) {
let packageJSONUrl = new URL('./package.json', resolved)
while (true) {
const packageJSONPath = packageJSONUrl.pathname
if (packageJSONPath.endsWith('node_modules/package.json')) {
break
}
const packageConfig = packageJsonReader.read(fileURLToPath(packageJSONUrl), {
__proto__: null,
specifier: resolved,
isESM: true,
})
if (packageConfig.exists) {
return packageConfig
}
const lastPackageJSONUrl = packageJSONUrl
packageJSONUrl = new URL('../package.json', packageJSONUrl)
// Terminates at root where ../package.json equals ../../package.json
// (can't just check "/package.json" for Windows support).
if (packageJSONUrl.pathname === lastPackageJSONUrl.pathname) {
break
}
}
const packageJSONPath = fileURLToPath(packageJSONUrl)
return {
__proto__: null,
pjsonPath: packageJSONPath,
exists: false,
main: undefined,
name: undefined,
type: 'none',
exports: undefined,
imports: undefined,
}
}
To summarize, getPackageScopeConfig
function calls C++ 3 times from the following functions:
new URL(...)
callsinternalBinding('url').parse()
C++ methodpath.fileURLToPath()
callsnew URL()
if the input is a stringpackageJsonReader.read()
callsfs.readFileSync()
C++ method
Moving this whole function to C++ enabled us to reduce the number of C++ calls to 1 to 1. This conversion also forced us to implement url.fileURLToPath()
in C++.
Results
The PR that contains these changes can be found on Github.
On a real-world Svelte application, the results showed 5% faster ESM execution. It also reduced the size of the cache stored by the loader by avoiding unnecessary fields.
❯ hyperfine 'node ../sveltejs-realworld/node_modules/vite/dist/node/cli.js --version' 'out/Release/node ../sveltejs-realworld/node_modules/vite/dist/node/cli.js --version' -w 10
Benchmark 1: node ../sveltejs-realworld/node_modules/vite/dist/node/cli.js --version
Time (mean ± σ): 101.4 ms ± 0.6 ms [User: 96.6 ms, System: 10.8 ms]
Range (min … max): 100.3 ms … 102.5 ms 28 runs
Benchmark 2: out/Release/node ../sveltejs-realworld/node_modules/vite/dist/node/cli.js --version
Time (mean ± σ): 96.3 ms ± 0.5 ms [User: 90.9 ms, System: 10.1 ms]
Range (min … max): 95.6 ms … 98.1 ms 30 runs
Summary
out/Release/node ../sveltejs-realworld/node_modules/vite/dist/node/cli.js --version ran
1.05 ± 0.01 times faster than node ../sveltejs-realworld/node_modules/vite/dist/node/cli.js --version