Repository navigation
[Bug?]: PnP ESM loader does not cache package.json scope lookups, causing O(files × directory depth) filesystem calls #7258
Description
Self-service
- I'd be willing to implement a fix
Describe the bug
When pnpEnableEsmLoader is enabled, the generated PnP ESM loader performs an uncached package-scope lookup for every .js file it loads.
For each file, it walks toward the filesystem root looking for package.json. Neither successful nor failed lookups are cached.
For example, loading 200 files from lib/qwe/asd/zxc/*.js causes Yarn to check all of these paths 200 times each:
lib/qwe/asd/zxc/package.json
lib/qwe/asd/package.json
lib/qwe/package.json
lib/package.json
package.json
On a regular Linux filesystem this is measurable but relatively small. On a Podman/Docker bind mount on macOS, where the filesystem calls cross the Podman VM / VirtioFS boundary, it can increase application startup from seconds to minutes. In my case a pre-built Node.js service startup time is 3 minutes (!) inside a container vs. a couple of seconds locally using tsx.
Node's native package-scope handling caches these lookups. Registering only .pnp.cjs, without .pnp.loader.mjs, retains that behavior.
To reproduce
A repo with detailed examples and scripts to reproduce and benchmark: https://github.com/the-spyke/yarn-pnp-package-json-cache-repro
The script generates a nested structure of required depth lib/level-1/level-2/level-3/module-0.js which then could be tested simply by:
$ time node \
--no-warnings \
--require ./.pnp.cjs \
--experimental-loader ./.pnp.loader.mjs \
entry.mjsResults are like this:
v24.18.1
plain Node
real 0.05
user 0.04
sys 0.01
PnP ESM loader
real 0.91
user 0.89
sys 0.08
There is also a script to run the benchmark and strace in a container.
Environment
System:
OS: Linux 7.0 Ubuntu 26.04 LTS 26.04 LTS (Resolute Raccoon)
CPU: (20) x64 12th Gen Intel(R) Core(TM) i7-12700H
Binaries:
Node: 26.7.0 - /tmp/xfs-431c4a78/node
Yarn: 4.18.0 - /tmp/xfs-431c4a78/yarn
npm: 11.19.0 - /run/user/1000/fnm_multishells/79136_1786803925419/bin/npm
Podman: 5.7.0
Images:
node:24.18-alpine3.23 (currently Node 24.18.1)
node:26.7-alpine3.24 (currently Node 26.7.0)Additional context
Suspected cause
The PnP ESM load hook calls getFileFormat() for every file:
| const format = loaderUtils.getFileFormat(filePath); |
For .js, getFileFormat() calls readPackageScope():
berry/packages/yarnpkg-pnp/sources/esm-loader/loaderUtils.ts
Lines 52 to 58 in 57081c0
| case `.js`: { | |
| const pkg = nodeUtils.readPackageScope(filepath); | |
| // assume CJS for files outside of a package boundary | |
| if (!pkg) | |
| return `commonjs`; | |
| return pkg.data.type ?? `commonjs`; | |
| } |
readPackageScope() walks every parent, while readPackage() performs a new existsSync and readFileSync on every call:
berry/packages/yarnpkg-pnp/sources/loader/nodeUtils.ts
Lines 9 to 35 in 57081c0
| export function readPackageScope(checkPath: NativePath) { | |
| const rootSeparatorIndex = checkPath.indexOf(npath.sep); | |
| let separatorIndex; | |
| do { | |
| separatorIndex = checkPath.lastIndexOf(npath.sep); | |
| checkPath = checkPath.slice(0, separatorIndex); | |
| if (checkPath.endsWith(`${npath.sep}node_modules`)) | |
| return false; | |
| const pjson = readPackage(checkPath + npath.sep); | |
| if (pjson) { | |
| return { | |
| data: pjson, | |
| path: checkPath, | |
| }; | |
| } | |
| } while (separatorIndex > rootSeparatorIndex); | |
| return false; | |
| } | |
| // https://github.com/nodejs/node/blob/e817ba70f56c4bfd5d4a68dce8b165142312e7b6/lib/internal/modules/cjs/loader.js#L284-L313 | |
| export function readPackage(requestPath: NativePath) { | |
| const jsonPath = npath.resolve(requestPath, `package.json`); | |
| if (!fs.existsSync(jsonPath)) | |
| return null; | |
| return JSON.parse(fs.readFileSync(jsonPath, `utf8`)); |
There is no cache in this path. Yarn's separate node/package_config.js implementation does have a packageJSONCache, but it is not used by loaderUtils.getFileFormat().
As a test, I added a Map to readPackage() caching both parsed manifests and null results. In the same Podman benchmark this reduced startup to:
Node 24: 0.17s
Node 26: 0.16s
In an equivalent host strace, matching package.json calls dropped from 1,200 to 6.
Activity
the-spyke commentedon Aug 15, 2026
On a side note, if you Google why running a PnP app is slow inside a container, you would find answers like it is because VirtFS is slow and suggestions to use
node-linkeror bake dependencies. Turns out it is not and fixing this bug should be huge.