Skip to content

[Bug?]: PnP ESM loader does not cache package.json scope lookups, causing O(files × directory depth) filesystem calls #7258

Open

Description

@the-spyke

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.mjs

Results 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():

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:

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

  1. the-spyke commented on Aug 15, 2026

    @the-spyke
    Author

    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-linker or bake dependencies. Turns out it is not and fixing this bug should be huge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions