Is there an existing issue for this?
This issue exists in the latest npm version
This is not just a request to bump a dependency for a CVE
Current Behavior
When npm install <pkg>@<version> is given a --min-release-age that the requested
version cannot satisfy, npm can loop without ever terminating instead of failing with
ETARGET.
The correct behaviour does happen in small projects. With only eslint in the
manifest (70 packages in the lockfile), the same command fails cleanly in ~1 second:
npm error code ETARGET
npm error notarget No matching version found for eslint@10.8.1 with a date before 8/4/2026, 11:08:22 AM.
Add the rest of the eslint ecosystem (182 packages) and the identical command runs
indefinitely with no output and no error. I have let the manifest below run 9
minutes on npm 12.0.2 without it completing or printing anything, and the same
command ran 52 minutes in CI before the job was cancelled.
--loglevel=silly shows the resolver spinning on a single packument. Over 90
seconds:
| Count |
Line |
| 7779 |
npm silly packumentCache full:https://registry.npmjs.org/eslint cache-miss |
| 7778 |
npm silly packumentCache full:https://registry.npmjs.org/eslint set size:2048190 disposed:false |
| 7777 |
npm silly fetch manifest eslint@10.8.1 |
| 3898 |
npm warn ERESOLVE overriding peer dependency |
Only 31 HTTP fetches occur in total, so this is not network-bound — it is the
in-memory packument cache failing to retain the entry. Note 7778 set calls,
7779 cache-miss, and zero dispose events. The entry is never evicted; it is
never admitted in the first place.
That appears to be by design in PackumentCache
(node_modules/@npmcli/arborist/lib/packument-cache.js):
sizeCalculation: (p) => {
if (!p[sizeKey]) {
return maxEntrySize + 1
}
if (p[sizeKey] < 10_000) {
return p[sizeKey] * 2
}
if (p[sizeKey] < 1_000_000) {
return Math.floor(p[sizeKey] * 1.5)
}
// It is less beneficial to store a small amount of super large things
// at the cost of all other packuments.
return maxEntrySize + 1
},
Any packument whose _contentLength is >= 1,000,000 returns maxEntrySize + 1, so
lru-cache refuses the entry. eslint's full packument is 2,048,190 bytes, matching
the logged set size:2048190 exactly. It is therefore permanently uncacheable, and
every retry re-reads and re-parses ~2 MB.
The cache had ample room, so this is the size branch rather than memory pressure:
npm silly packumentCache heap:4496293888 maxSize:1124073472 maxEntrySize:562036736
On its own that is only a performance tax. What makes it fatal here is that
--min-release-age renders the requested version unsatisfiable, which puts the
resolver on a retry/backtrack path. Each iteration pays a full uncached 2 MB fetch
and parse, and with eslint's 427 versions and its version-locked @eslint/*
peerOptional graph the loop does not converge.
Expected Behavior
npm install eslint@10.8.1 --min-release-age=14 should fail with ETARGET the same
way it does in a small project, since the requested version is unambiguously
excluded by the gate. Whatever the resolver does internally, an explicitly requested
version that the gate rejects should be a fast, deterministic failure rather than an
unbounded loop.
Steps To Reproduce
Requested version must be younger than the gate. eslint@10.8.1 was published
2026-08-07, so --min-release-age=14 excludes it as of 2026-08-18; adjust the
version or the gate if reproducing later.
mkdir /tmp/npm-min-release-age-hang && cd /tmp/npm-min-release-age-hang
cat > package.json <<'JSON'
{
"name": "repro", "version": "1.0.0", "private": true,
"devDependencies": {
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^10.0.1",
"@typescript-eslint/eslint-plugin": "^8.50.0",
"@typescript-eslint/parser": "^8.50.0",
"@vitest/eslint-plugin": "^1.6.18",
"eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.4",
"prettier": "^3.7.4",
"typescript": "^6.0.3",
"vitest": "^4.1.7"
}
}
JSON
# 1. Create the lockfile (182 packages)
npm install --package-lock-only --ignore-scripts
# 2. Hangs indefinitely -- no output, no error
npm install eslint@10.8.1 --ignore-scripts --package-lock-only --min-release-age=14
Step 1 already resolves eslint to 10.8.1, so step 2 hangs while re-requesting a
version the lockfile already contains — the gate rejects it regardless. The
real-world shape hangs identically: replacing step 1 with
npm install eslint@10.8.0 --package-lock-only --ignore-scripts to pin the lockfile
at 10.8.0, then running step 2 as the 10.8.0 -> 10.8.1 bump, ran 5 minutes
without completing. That is how this surfaced, as a Dependabot patch update.
Contrast cases, all with --min-release-age=14 and all measured on npm 12.0.2:
| Case |
Result |
Manifest above (182 pkgs), eslint@10.8.1 (10d, gate rejects) |
no completion after 9 min |
Same, but lockfile pinned to 10.8.0 first (the real bump) |
no completion after 5 min |
eslint alone (70 pkgs), eslint@10.8.1 (10d, gate rejects) |
ETARGET in ~1s (correct) |
Manifest above, eslint@10.8.0 (24d, gate accepts) |
exit 0, sub-second |
So neither the graph nor the gate alone is sufficient; it needs a rejected target plus
the peer-coupled graph. A rejected target in a small project fails correctly, and the
same large graph resolves instantly when the target passes the gate.
Not dependent on --force (hangs with and without it), not dependent on any private
registry or proxy — reproduced against registry.npmjs.org directly.
Environment
- npm: reproduced on 12.0.2 (latest) and 11.12.1
- Node.js: v24.15.0
- OS: macOS 15 (Darwin 25.5.0, arm64)
- Registry: https://registry.npmjs.org/ (isolated empty user/global npmrc, fresh cache dir)
Also reproduced on npm 11.17.0 / Ubuntu 24.04 inside Dependabot's updater container
against an AWS CodeArtifact registry, where the proxy logged the same packument
being refetched 21,732 times over 52 minutes before the job was cancelled.
Possible directions
- Fail fast when an explicitly requested
name@version is excluded by the gate,
before entering the retry path.
- Bound or memoize the retry loop so a rejected target cannot be reconsidered
indefinitely.
- Reconsider the >= 1,000,000 byte uncacheable branch. A negative-lookup marker, or
caching a version-index projection rather than the whole packument, would stop
large-packument packages from paying an unbounded re-parse cost. typescript
(15.5 MB) and vitest (2.2 MB) are in the same category as eslint.
Is there an existing issue for this?
This issue exists in the latest npm version
This is not just a request to bump a dependency for a CVE
Current Behavior
When
npm install <pkg>@<version>is given a--min-release-agethat the requestedversion cannot satisfy, npm can loop without ever terminating instead of failing with
ETARGET.The correct behaviour does happen in small projects. With only
eslintin themanifest (70 packages in the lockfile), the same command fails cleanly in ~1 second:
Add the rest of the eslint ecosystem (182 packages) and the identical command runs
indefinitely with no output and no error. I have let the manifest below run 9
minutes on npm 12.0.2 without it completing or printing anything, and the same
command ran 52 minutes in CI before the job was cancelled.
--loglevel=sillyshows the resolver spinning on a single packument. Over 90seconds:
npm silly packumentCache full:https://registry.npmjs.org/eslint cache-missnpm silly packumentCache full:https://registry.npmjs.org/eslint set size:2048190 disposed:falsenpm silly fetch manifest eslint@10.8.1npm warn ERESOLVE overriding peer dependencyOnly 31 HTTP fetches occur in total, so this is not network-bound — it is the
in-memory packument cache failing to retain the entry. Note 7778
setcalls,7779
cache-miss, and zerodisposeevents. The entry is never evicted; it isnever admitted in the first place.
That appears to be by design in
PackumentCache(
node_modules/@npmcli/arborist/lib/packument-cache.js):Any packument whose
_contentLengthis >= 1,000,000 returnsmaxEntrySize + 1, solru-cacherefuses the entry. eslint's full packument is 2,048,190 bytes, matchingthe logged
set size:2048190exactly. It is therefore permanently uncacheable, andevery retry re-reads and re-parses ~2 MB.
The cache had ample room, so this is the size branch rather than memory pressure:
On its own that is only a performance tax. What makes it fatal here is that
--min-release-agerenders the requested version unsatisfiable, which puts theresolver on a retry/backtrack path. Each iteration pays a full uncached 2 MB fetch
and parse, and with eslint's 427 versions and its version-locked
@eslint/*peerOptional graph the loop does not converge.
Expected Behavior
npm install eslint@10.8.1 --min-release-age=14should fail withETARGETthe sameway it does in a small project, since the requested version is unambiguously
excluded by the gate. Whatever the resolver does internally, an explicitly requested
version that the gate rejects should be a fast, deterministic failure rather than an
unbounded loop.
Steps To Reproduce
Requested version must be younger than the gate.
eslint@10.8.1was published2026-08-07, so
--min-release-age=14excludes it as of 2026-08-18; adjust theversion or the gate if reproducing later.
Step 1 already resolves
eslintto10.8.1, so step 2 hangs while re-requesting aversion the lockfile already contains — the gate rejects it regardless. The
real-world shape hangs identically: replacing step 1 with
npm install eslint@10.8.0 --package-lock-only --ignore-scriptsto pin the lockfileat
10.8.0, then running step 2 as the10.8.0 -> 10.8.1bump, ran 5 minuteswithout completing. That is how this surfaced, as a Dependabot patch update.
Contrast cases, all with
--min-release-age=14and all measured on npm 12.0.2:eslint@10.8.1(10d, gate rejects)10.8.0first (the real bump)eslintalone (70 pkgs),eslint@10.8.1(10d, gate rejects)ETARGETin ~1s (correct)eslint@10.8.0(24d, gate accepts)So neither the graph nor the gate alone is sufficient; it needs a rejected target plus
the peer-coupled graph. A rejected target in a small project fails correctly, and the
same large graph resolves instantly when the target passes the gate.
Not dependent on
--force(hangs with and without it), not dependent on any privateregistry or proxy — reproduced against
registry.npmjs.orgdirectly.Environment
Also reproduced on npm 11.17.0 / Ubuntu 24.04 inside Dependabot's updater container
against an AWS CodeArtifact registry, where the proxy logged the same packument
being refetched 21,732 times over 52 minutes before the job was cancelled.
Possible directions
name@versionis excluded by the gate,before entering the retry path.
indefinitely.
caching a version-index projection rather than the whole packument, would stop
large-packument packages from paying an unbounded re-parse cost.
typescript(15.5 MB) and
vitest(2.2 MB) are in the same category aseslint.