Internals Decoded
Topics/npm
AI Internals

How npm Resolves ^1.2.3 Into a Tarball URL

From semver range to exact tarball: see how npm's resolver pipeline (Arborist, Pacote, pick-manifest) picks the winning version and integrity hash.

npmsemverpacotepackage-manager
10 min readUpdated Jun 24, 2026

The string ^1.2.3 in your package.json is not a version. The moment you run npm install, it is already dead. Before any package byte lands in node_modules, that range gets boiled down to one exact version, one tarball URL, and one hash. The resolver’s job is to kill the ambiguity. And it does so without a global solver. npm does not try to find a mathematically optimal assignment across all dependencies. It chooses a version greedily for each unmet edge, applies deduplication later, and relies on the lockfile for reproducibility. If you expected a full SAT solver under the hood, this might surprise you.

A mental model helps. Picture a concierge at a large hotel. You ask for “a room in any 4‑star hotel in this city, rating above 4.2, available this Friday.” The concierge queries the chain’s catalog of properties, discards everything below 4 stars or below the rating threshold, picks the property with the highest score among those with rooms free on Friday, and hands you a reservation with an exact room number and key code. The concierge does not solve a giant optimization puzzle involving every traveler’s request at once. The job is to translate a loose spec into one concrete, bookable thing. npm’s version resolution works the same way for a dependency spec like foo@^1.2.3. It consults the registry catalog, filters by semver and platform constraints, selects the highest eligible version, and returns a concrete tarball URL and integrity hash.

The Resolution Pipeline: From Spec to Locked Tarball

Three major components cooperate to turn ^1.2.3 into a download URL. The CLI and Arborist construct the dependency tree. Pacote speaks the registry protocol and resolves specifiers. The @npmcli/pick-manifest library picks the winning version. Arborist Pacote pick-manifest

Arborist is the internal tree builder used by npm v7 and later. It loads the existing lockfile (or scans node_modules if none exists), applies the user’s requested changes, and then attempts to grow an ideal tree where every dependency edge points to a concrete node. For an edge like foo@^1.2.3, Arborist knows the name and the range but not the version. It hands the spec to Pacote for resolution. In return it gets a manifest object that contains the exact version, a dist.tarball URL, and an integrity hash. Arborist wires that into the tree, records the decision in package-lock.json, and schedules the actual tarball download. The lockfile entry ends up holding the resolved URL and integrity, not just a version number, so that future installs replay the same download. npm lockfile docs

Parsing ^1.2.3 Into a Semver Interval

Before any network call, ^1.2.3 must become a precise set of allowed version numbers. npm uses the node-semver library for that. node-semver

A caret range for a major version above zero is straightforward: ^1.2.3 means every version <2.0.0 that is at least 1.2.3. Internally, node-semver expands it to the comparator set >=1.2.3 <2.0.0. This set is a half‑open interval. A version satisfies the range if it meets every comparator in the set. Semver precedence then sorts versions by numeric components, so 1.9.4 ranks higher than 1.2.4. Pre‑release versions are ordered before the corresponding normal version and are not included in range satisfaction unless called out explicitly. So ^1.2.3 will never match 1.2.4‑alpha.0. Semantic Versioning spec

The tilde operator works similarly: ~1.2.3 expands to >=1.2.3 <1.3.0. The nuances for 0.x.y versions and for omitted components are all formalized in the node-semver code. The key insight: the interval is completely determined with zero network traffic. npm knows exactly what ^1.2.3 means before it touches the registry. The registry merely supplies a list of existing versions to test against that fixed interval.

The Registry as a Version Catalog

The npm registry organizes package metadata in a document called a packument. A packument is a JSON blob, served from a URL like https://registry.npmjs.org/foo, that contains a versions object keyed by exact version strings. Each version entry holds a manifest‑like structure with the dist object (tarball URL, integrity hashes) and other fields such as engines and os. The packument also carries dist-tags like latest, mapping logical labels to concrete versions. npm Registry API

Pacote is the library npm uses to fetch packuments, manifests, and tarballs. It exposes both a programmatic API and a CLI that mirrors npm’s own resolution logic. The command pacote packument foo prints the full packument. pacote resolve foo@^1.2.3 takes the specifier, resolves it, and prints something like foo@1.9.4 along with the final tarball URL. Under the hood, Pacote fetches the packument for foo, caches it via cacache, and then delegates the version selection to @npmcli/pick-manifest. Pacote README

How the Solver Picks One Version

The pick-manifest library does the actual filtering. It receives the packument, the original specifier (now parsed into a node-semver range object), and optional constraints like the current Node.js version and platform. It proceeds in three steps. pick-manifest source

First, it extracts all version strings from the packument’s versions object and keeps only those that satisfy the semver range. For ^1.2.3, that is every 1.x.y where the pair (x, y) is lexicographically at least (2, 3).

Second, it applies environment constraints. The engines field in the packument entry declares which Node.js versions the package claims to support. The cpu and os fields can restrict platforms. If the current runtime does not match, the version is discarded. This is not just theoretical. A concrete example lives inside npm’s own update notifier. In the past, the notifier would fetch npm@latest without checking engines. If the latest npm version required a newer Node.js than the current runtime, the suggestion was misleading. The fix changed the logic to use pacote.manifest('npm@*'), which hands the full range to pick-manifest. The algorithm then picks the highest version whose engines field is compatible with the current Node.js. If the latest version is incompatible, it falls back to the highest compatible version. npm update‑notifier source

Third, among the versions that survive semver and environment filters, the algorithm orders them by semver precedence and takes the highest one. The result is a single version string like 1.9.4. Pacote then extracts that version’s entry from the packument and returns it as the manifest. This local decision does not consider what other packages in the project might prefer. Arborist will later try to deduplicate when it sees multiple edges converging on the same package, but the one‑edge resolution itself is greedy.

From Selected Version to Tarball URL

The manifest object returned by Pacote carries the dist information from the packument. The dist.tarball field is a URL like https://registry.npmjs.org/foo/-/foo-1.9.4.tgz. The dist.integrity field contains a content‑addressable hash, typically in Subresource Integrity format, that npm uses to verify the downloaded tarball. npm lockfile docs

You can replicate a piece of this resolution manually. The command npm view foo dist.tarball prints the tarball URL for the latest version. A more surgical query, npm view foo@^1.2.3 dist.tarball, will return the tarball URL for the exact version that npm would pick for that range. Under the covers, the npm CLI’s view command follows the same Pacote resolution path.

Arborist receives this resolved data and encodes it in the lockfile. In npm v7+ lockfiles, the packages node stores, for each package, a resolution object that includes resolved (the tarball URL) and integrity. When npm installs from a lockfile, it skips the resolution phase entirely. It reads the exact URL and hash directly, downloads the tarball, verifies the integrity, and unpacks it into node_modules. The ^1.2.3 in package.json no longer matters; the lockfile records a snapshot of the resolution at a point in time.

Real‑World Implications

Understanding this pipeline changes how you think about reproducibility, security, and performance. A project with a committed lockfile is shielded from new registry releases. Even if a new version 1.10.0 is published, a npm ci replaying an old lockfile will fetch the exact tarball it recorded, not re‑resolve against today’s registry. That makes CI builds deterministic.

The integrity hash is the bridge between resolution and trust. npm verifies every tarball against the hash before unpacking. The registry supplies both the tarball and the hash in the packument. If the registry were compromised after you resolved once, the lockfile’s hash would catch a tampered tarball. That is why integrity fields in lockfiles should always be committed.

Pacote’s caching layer also depends on the tarball URL and integrity. By content‑addressing tarballs, npm can share a single cached copy across every project on a machine, even when they resolve the same version through different range shortcuts. The lockfile is the only on‑disk record that needs to change between projects; the tarball bytes live once in the cache.

FactDetail
Resolution algorithmGreedy per‑edge, deduplication applied later by Arborist
Semver librarynode-semver; expands ^1.2.3 to >=1.2.3 <2.0.0
Registry data structurePackument JSON with versions, dist‑tags, per‑version dist
Tarball selectionHighest semver‑precedence version that satisfies the range and environment constraints
Lockfile v2 formatStores resolved (tarball URL) and integrity in packages object
Pre‑release handlingNot included in ^ or ~ ranges unless specified explicitly
Key library that picks version@npmcli/pick-manifest (used by Pacote)
CLI command to peek at resolutionnpm view foo@^1.2.3 dist.tarball or pacote resolve foo@^1.2.3

Frequently Asked Questions

Q: Does npm solve version constraints across all packages simultaneously?
No. Each dependency edge is resolved independently by Pacote’s pick-manifest, which returns one best version for that edge. Arborist then builds a tree and tries to deduplicate when the same package appears at a compatible version for multiple dependents. If deduplication is not possible (conflicting ranges), nested copies are created. There is no global optimization pass.

Q: How does ^1.2.3 behave when a package has pre‑release versions like 1.2.4‑0?
The node-semver implementation excludes pre‑release tags from range satisfaction by default. The range >=1.2.3 <2.0.0 will not match 1.2.4‑0. Pre‑release versions are only considered when the specifier includes a pre‑release tag explicitly, for example ^1.2.3‑0.

Q: What happens if the version that best satisfies the range requires a Node.js version I am not running?
pick-manifest checks the engines field in the packument entry. If the candidate version declares engine requirements incompatible with the current runtime, that version is skipped. The algorithm falls back to the next highest version that is compatible. If no version satisfies both the range and the engine constraint, resolution fails with an appropriate error.

Q: Where exactly is the tarball URL stored in the lockfile, and why is it important?
In npm v7 and later, the lockfile’s packages section contains, for each package, a resolution field with resolved (the full tarball URL) and integrity (the content hash). This decouples the install process from semver re‑evaluation. Any machine that runs npm ci with that lockfile will download the exact same tarball, not a different version that might now satisfy the original range.

Q: Can I resolve a specifier to a tarball URL without modifying my node_modules?
Yes. npm view foo@^1.2.3 dist.tarball prints the URL that npm would use. Alternatively, pacote resolve foo@^1.2.3 shows the resolved identifier and the tarball target. These commands use the same resolution path as a full install but do not touch the project tree or lockfile.

If you want this kind of breakdown every week—how real systems actually work under the hood—subscribe to Internals Decoded at internalsdecoded.com. Next week we will dissect npm’s content‑addressable cache and show exactly how it avoids downloading the same tarball twice across every project on your machine.

Sources

Weekly internals
One breakdown every week
How Docker, Git, Kubernetes, VS Code, and the tools you use every day actually work. No fluff. Built for senior engineers.
Subscribe free →
Explore more
/topics
Browse all topics
More deep dives on AI internals — inference, agents, vector databases, MCP, and more.