Visitar URL original
fix(perf): lazy load arborist and pacote in libnpmexec by azchohfi · Pull Request #10096 · npm/cli · GitHub
Skip to content

fix(perf): lazy load arborist and pacote in libnpmexec - #10096

Open
azchohfi wants to merge 1 commit into
npm:latestfrom
azchohfi:perf/lazy-load-arborist-pacote
Open

azchohfi wants to merge 1 commit into
npm:latestfrom
azchohfi:perf/lazy-load-arborist-pacote

Conversation

@azchohfi

@azchohfi azchohfi commented Oct 8, 2026 •

Copy link
Copy Markdown

Related: #10084 (Windows shims start node once per call) and #10097 (does
not list network interfaces at start-up). Each removes a different part of npm's start-up cost;
they touch different files and the savings add up.

Summary

npx <bin> / npm exec -- <bin> with <bin> already in node_modules/.bin (npx tsc,
npx eslint, npx vitest run …) loads 560 modules before it spawns the bin. About 420 of
them are @npmcli/arborist and pacote and what they pull in: all of pacote's fetchers,
make-fetch-happen, @npmcli/agent, sigstore/tuf-js, cacache, tar… That path uses none
of them. libnpmexec requires both at the top of lib/index.js, and the only call on this path,
hasPkgBin(), goes through pacote.manifest(dir) just to read the project's package.json.

This PR, in workspaces/libnpmexec/lib/index.js:

  1. Requires @npmcli/arborist and pacote on first use, and
    ./strict-allow-scripts-preflight.js (which reaches into Arborist) where it is called. This is
    the approach of fix: lazy loading of arborist and pacote #6225 and fix(perf): lazy loading optimizations #7388 for lib/commands/*, applied to libnpmexec, which they did not
    touch.
  2. hasPkgBin() calls PackageJson.normalize(dir) directly. That is what pacote's
    DirFetcher.manifest() does for a directory spec; pacote only adds _resolved/_integrity/
    _from, which hasPkgBin does not read. Because pacote is no longer involved, the
    allowDirectory option it used to be passed has nothing to gate.
  3. npm exec --call / npx -c without --package runs the command before
    Arborist.loadActual().
    With no packages, the loaded tree was never read.

With the patch, npx <local bin> and npx -c <cmd> load 140 modules. Arborist and pacote load
exactly as before whenever a package has to be looked up or installed.

Numbers

  • Setup:
    • npm 12.2.0 from npm pack of latest (b317f16), against the same tarball with this commit;
    • official Node.js 26.7.0; Windows 11; i9-14900K;
    • a Dev Drive (ReFS), and the system drive (NTFS, Defender real-time scanning);
    • node npx-cli.js … directly (no shim), in a TypeScript project.
  • Method: 20 interleaved passes after warm-up; medians in ms, and the paired per-pass
    difference with its 95% CI.
Windows 11 before after saved
npx --no-install tsc --version, Dev Drive 542 275 259 (227–286)
same, system drive 830 353 455 (438–501)
npx --no-install -c "tsc --version", Dev Drive 565 277 286 (259–352)
same, system drive 885 360 518 (480–564)
npx --no-install -p typescript tsc --version (needs Arborist: control), Dev Drive 581 547 6 (−11–41)
npm --version (control), Dev Drive 116 115 0.5 (−3–6)

tsc --version itself takes 73 ms and node -e 0 45 ms on this machine, so npm's own share of
npx tsc --version goes from about 470 to about 200 ms on the Dev Drive. A second run, in a clean
user environment and with the machine under more load, saved more: 318 ms (303–336) on the Dev
Drive and 518 ms (505–552) on the system drive.
The module and fs call counts, before and after, are the same on Windows and on Linux, where the
change was also tested.

Behaviour

  • Unchanged: every path that needs a package lookup or install (registry specs, --package,
    directory specs, the npx cache, global bins).
  • hasPkgBin: reads the same normalized package.json as before.
  • One edge: a corrupt node_modules used to make npx -c '<cmd>' fail inside loadActual();
    now it runs the command, as npm run does.

Tests

  • New: workspaces/libnpmexec/test/local.js › "call without packages does not load the local
    tree". It counts loadActual() calls through the existing Arborist mock pattern, and fails
    without change 3.
  • Windows 11, Node 26.7.0:
    • node . run test -w workspaces/libnpmexec: passes, 100% coverage;
    • node . run lint: clean;
    • node . run test: passes except test/bin/windows-shims.js, test/lib/commands/publish.js
      and test/lib/commands/stage/index.js, which fail the same way on unmodified latest on the
      test machine (a PowerShell profile message, and scripts that call touch).
  • Linux: libnpmexec passes with 100% coverage.

Composition

References

`npm exec` / `npx` required `@npmcli/arborist` and `pacote` at the top of
libnpmexec, so running a bin that is already in `node_modules/.bin`
loaded about 420 modules (fetchers, make-fetch-happen, sigstore, tuf-js,
cacache, tar, ...) that this path never uses.

- require arborist and pacote on first use
- read the local package.json for `hasPkgBin` with
  `@npmcli/package-json` directly, which is what pacote's DirFetcher
  does for a directory spec
- with `--call` and no `--package`, run the command without reading the
  local tree, whose result was never used

`npx <local bin>` and `npx -c <cmd>` now load 140 modules instead of 560
before spawning the command.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant