Package Managers in Depth: npm, pnpm, Yarn & Bun

You type npm install and, a few seconds later, a node_modules folder appears with four hundred packages in it that you never asked for. Where did they come from? Which versions? Why is the folder 300 MB? And why does the exact same command sometimes produce a slightly different tree on a teammate’s laptop?

A package manager answers all of those questions. Its job sounds simple — read the list of dependencies you declared, go get them — but the interesting part is how the packages land on disk, because that layout decides what your code can import, how much space it eats, and whether “works on my machine” turns into “breaks in CI.” The four tools people reach for in 2026 — npm, pnpm, Yarn, and Bun — do the same job with meaningfully different strategies, and the differences are worth understanding before you commit a repo to one of them.

This picks up from Modules, npm & Semantic Versioning, which covers package.json, the exports field, and what a version range like ^1.2.3 actually means. Here we go one level down: into the resolver, the lockfile, and the folder on disk.

What “install” actually does

Every one of these tools runs the same four phases. The vocabulary is shared even when the mechanics differ.

readpackage.jsonresolverange to versionfetchtarballs to cachelockwrite lockfilelinknode_modulesThe tools differ mostly in the last step — how files end up under node_modules.
The install pipeline every package manager follows: read what you declared, resolve each range to a concrete version, fetch tarballs into a shared cache, record the exact result in a lockfile, and lay the files out under node_modules.

Resolution is the hard part. Your package.json says "express": "^5.1.0", but that’s a range, not a version. The resolver walks your whole dependency graph — your direct dependencies, their dependencies, and so on — and picks one concrete version for each package that satisfies every range pointing at it. The output of that search is the lockfile.

Fetching pulls the resolved tarballs from a registry (npmjs.com by default) into a machine-wide cache, so the next project that needs the same version doesn’t download it again.

Linking is where the four tools diverge, and it’s the rest of this article.

npm: one flat, hoisted folder

npm — the default that ships with Node, currently at version 11 — builds a flat node_modules. Rather than nesting each package’s dependencies inside it, npm hoists everything it can to the top level. If your app depends on express, and express depends on qs, both express and qs end up as siblings at the top of node_modules.

Hoisting exists for a good reason. Node’s module resolution walks up the directory tree looking for node_modules, so a package sitting at the top is visible to everything. Put one copy of qs at the top and every package that needs it finds it there — no duplication. But hoisting only works cleanly when everyone agrees on one version. The moment two packages need incompatible versions of the same dependency, npm has to nest.

node_modules/ (flat, hoisted)expresschart-libqslodash 4.17.21hoisted: one copy, visible to everyonethat accepts this versionexpress + chart-lib both read the top-level copiesbut chart-lib needs lodash 3 — incompatiblenode_modules/chart-lib/node_modules/lodash 3.10.1 (nested copy)duplicated on disk
npm hoists shared dependencies to the top of node_modules. When two packages need incompatible versions (lodash 4 vs 3), only one can win the top slot; the loser gets nested and duplicated on disk.

So npm’s tree is flat until a conflict forces nesting, at which point you get duplicate copies of a package at different depths. A big app can carry the same library three or four times over. That’s the disk cost. But the flat layout has a second, subtler consequence that bites correctness, not just space.

The phantom-dependency problem

Because everything gets hoisted to the top of node_modules, your code can import any package that happens to be up there — including packages you never listed in your own package.json. They’re only present because something else you depend on pulled them in.

your package.json declares:dependencies:express onlyyour code writes:import qs from ‘qs’ <- never declaredhoisted node_modules/express (you declared this)qs (pulled in BY express)qs is visible, so the import resolves…for now.express upgrades, drops qs, or hoists it deeper -> your import breaks with no code change. That is the trap.
A phantom dependency: your package.json only declares express, but qs is hoisted to the top of node_modules because express needs it. Your import of qs works today — until express upgrades and stops depending on that version of qs.

This is a phantom dependency: your code depends on qs, uses it, ships it — but nothing in your manifest says so. Everything works until the day express bumps a version and no longer pulls in that qs, or npm decides to hoist it somewhere your resolver can’t see it. Now import qs from 'qs' throws Cannot find module, and you changed nothing. The bug was latent for months.

npm ci and the lockfile

npm install is allowed to change the lockfile — if a range now resolves to a newer version, it writes the update. That’s the wrong behavior in CI, where you want the exact tree the lockfile already describes and nothing else. That’s what npm ci is for:

# Local dev — may update package-lock.json
npm install

# CI / reproducible — install strictly from the lockfile, or fail
npm ci

npm ci deletes node_modules, reads package-lock.json (never package.json for versions), and installs exactly what’s pinned. If the lockfile and package.json disagree, it errors instead of silently “fixing” things. It’s faster too, because it skips the whole resolution phase. Use it in every automated build.

npm’s lockfile, package-lock.json, is at lockfileVersion: 3 as of npm 11. Commit it. It’s the difference between “we all run the same code” and “it worked in the PR.”

pnpm — “performant npm,” at version 11.5.3 as of June 2026 — is the sensible default for a new project in 2026. Vue, Vite, and a large slice of the tooling ecosystem ship with it. It fixes both npm problems at once: the disk waste and phantom dependencies. The trick is a completely different on-disk model.

Every version of every package you’ve ever installed lives exactly once in a content-addressed store on your machine (run pnpm store path to see where). “Content-addressed” means files are keyed by a hash of their contents, so identical files are stored once no matter how many packages or projects share them. When a project needs a package, pnpm doesn’t copy files into node_modules — it creates hard links from the store. A hard link is a second name for the same bytes on disk, so the file costs no extra space.

global content-addressed store (once per machine)[email protected][email protected][email protected][email protected]keyed by file-content hash;shared bytes stored onceproject/node_modules/.pnpm/ (hard links into store)[email protected]/node_modules/express[email protected]/node_modules/qstop level (symlinks: declared only)express -> .pnpm/[email protected]only express is here. qs is NOT.Because qs is not symlinked at the top level,import qs from ‘qs’ in YOUR code fails to resolve.express can still reach qs (it is symlinked insideexpress’s own folder). Phantom deps: structurally gone.
pnpm keeps one content-addressed copy of every package on the machine. Each project's node_modules/.pnpm holds hard links into that store (no copy), and the top level holds symlinks only to the packages you actually declared.

The layout has two levels. A hidden node_modules/.pnpm/ directory holds one hard-linked folder per resolved package, and inside each of those, symlinks wire up that package’s own declared dependencies. Then the top level of node_modules gets a symlink only to the packages you declared in your package.json. So express sees qs (they’re linked inside express’s own subtree), but your code can’t — qs was never symlinked at your top level. Import something you didn’t declare and it simply fails to resolve. The phantom trap is closed by the shape of the folder, not by a lint rule you might forget.

pnpm’s commands mirror npm’s, so muscle memory transfers. The frozen-install equivalent of npm ci is:

pnpm install                      # dev; may update pnpm-lock.yaml
pnpm install --frozen-lockfile    # CI; install exactly from the lockfile or fail

Its lockfile is pnpm-lock.yaml — YAML, so it diffs more readably than npm’s JSON. Commit it, same as always. In CI, --frozen-lockfile is on by default when a lockfile is present, but naming it explicitly documents intent.

Yarn: Berry and Plug’n’Play

Yarn’s modern line — “Berry,” version 4.x, at 4.16.0 as of mid-2026 — took the boldest swing: it can skip node_modules entirely. Its default install strategy, Plug’n’Play (PnP), writes a single file called .pnp.cjs that contains the entire dependency graph — every package, every version, where each one lives in a zipped cache, and exactly which dependencies each package is allowed to see. Node loads that file at startup and resolves imports through it instead of walking folders.

yarn install                # respects yarn.lock; PnP by default
yarn install --immutable    # CI; fail if the lockfile would change

The payoffs are speed and strictness. There’s no giant node_modules to create or traverse, so cold installs are dramatically faster, and because PnP knows the exact allowed dependency set for every package, phantom dependencies are a hard error rather than a silent success. Yarn also enables Zero-Installs: commit the compressed cache (.yarn/cache) alongside .pnp.cjs and a fresh clone runs with no install step at all.

Yarn’s lockfile is yarn.lock. Commit it. If you adopt Zero-Installs, you also commit .yarn/cache — a deliberate trade of repo size for zero-friction clones.

Bun: the installer built into the runtime

Bun is a JavaScript runtime, but it also ships a package manager that happens to be the fastest of the group. bun install reads the same package.json and installs into a node_modules folder (hoisted, like npm’s), but it’s written in Zig with an optimized cache and aggressive parallelism, so cold installs finish in a fraction of the time. If you’re already running Bun, there’s little reason to shell out to another installer.

bun install                    # fast; writes bun.lock
bun install --frozen-lockfile  # CI; exact install from the lockfile

Since Bun 1.2, the default lockfile is bun.lock — a text file in JSONC format that reviews cleanly in pull requests. Earlier Bun used a binary bun.lockb, which was fast but impossible to read in a diff; the text format keeps the speed and fixes the review problem. If you have an old binary lockfile, migrate it once:

bun install --save-text-lockfile --frozen-lockfile --lockfile-only

Bun’s layout is hoisted, so it inherits npm’s phantom-dependency exposure — it optimizes install speed, not dependency strictness. That’s the main axis on which it differs from pnpm and Yarn PnP.

Lockfiles: the point of the whole exercise

Every tool writes a lockfile, and they all do the same job: turn the ranges in your package.json into an exact, reproducible answer. A range says “any 1.x at or above 1.2.4.” A lockfile says “1.7.2, and here’s its integrity hash so nobody can swap the bytes on you.” (For a refresher on what ^, ~, and exact ranges mean, see semver in the modules article.)

package.json“lib”: “^1.2.4”published versions on the registry1.1.01.2.41.5.01.7.22.0.0too lowsatisfieshighest matchmajor bumplockfile (pinned + verifiable)lib: 1.7.2resolved: registry.npmjs.org/lib/-/lib-1.7.2.tgzintegrity: sha512-Xk7d… (bytes must match this hash)
A range in package.json admits many published versions. Resolution picks the highest that satisfies every constraint, and the lockfile pins that one exact version plus an integrity hash — so the next install reproduces it byte for byte.

Two rules cover almost every situation:

  1. Commit your lockfile. It’s not a build artifact; it’s the record of what actually shipped. Without it, two installs a week apart can resolve different versions and you’ll debug a “phantom” regression that’s really just a silent dependency bump.
  2. Use frozen installs in CI. npm ci, pnpm install --frozen-lockfile, yarn install --immutable, bun install --frozen-lockfile. Each one refuses to touch the lockfile and fails loudly if package.json and the lockfile have drifted apart. That failure is a feature — it catches a forgotten npm install before it reaches production.

Running packages: npx, dlx, and scripts

Two things you do constantly. First, scripts — the "scripts" block in package.json is the same across all four tools, and each runs them the same way:

npm run build      # or: pnpm build, yarn build, bun run build

Second, running a CLI you don’t want to install permanently — a project scaffolder, a one-off codemod. Each tool has a “download, run once, throw away” command:

npx create-vite@latest        # npm
pnpm dlx create-vite@latest   # pnpm
yarn dlx create-vite@latest   # yarn
bunx create-vite@latest       # bun

npx will reuse an already-installed local binary if one exists, which is occasionally surprising; dlx always fetches a fresh, isolated copy. For “give me exactly this version, nothing from my project,” dlx (or bunx) is the more predictable choice.

Forcing a version: overrides and resolutions

Sometimes a transitive dependency — something you didn’t install directly, three levels down — has a bug or a security advisory, and you need to force a specific version across your entire tree without waiting for every intermediate package to update. Every tool has a field for this; only the name differs.

// npm and pnpm: "overrides"
{
  "overrides": {
    "qs": "6.14.0"
  }
}
// Yarn: "resolutions"
{
  "resolutions": {
    "qs": "6.14.0"
  }
}

pnpm reads its overrides from a "pnpm": &#123; "overrides": ... &#125; block (or pnpm-workspace.yaml in a monorepo). These are a scalpel, not a default tool — an override silently pins a version regardless of what any package asked for, so it can break a dependency that genuinely needed the old one. Reach for it to patch a vulnerability fast, then remove it once the ecosystem catches up.

If you’re managing many packages in one repo, the workspace features here — workspaces in npm/Yarn/Bun, pnpm-workspace.yaml in pnpm — deserve their own treatment; they’re covered in the monorepos article.

Try it: a semver range matcher

Resolution lives or dies on one question — does this version satisfy this range? Type a range and a list of versions below and watch which ones match. The satisfies() function is a small self-contained implementation of the common cases (^, ~, x wildcards, and exact), so you can read exactly how the decision is made.

interactiveWhich versions satisfy a range?

Real resolvers (npm’s semver library) handle far more — pre-release tags, >= and - ranges, || unions, build metadata — but the core idea is exactly this: a range defines a window, and resolution takes the highest published version inside it.

How to choose

There’s no single winner, but the decision is not hard.

pnpmNew project, no strong reason otherwise. Strict, disk-cheap, familiar commands. The 2026 default.BunAlready running Bun, or install speed is the priority and hoisted layout is fine.Yarn (PnP)Want maximum strictness and Zero-Installs, and your toolchain is PnP-compatible.npmZero setup, maximum compatibility, an existing repo already on it — no reason to migrate.
A quick decision guide. pnpm is the safe default for new projects; the others earn their place under specific constraints.

The honest summary: for a greenfield project in 2026, start with pnpm. It closes the phantom-dependency hole by construction, its disk savings compound across every repo on your machine, and the commands are close enough to npm that nobody on the team has to relearn anything. Choose one, pin it with Corepack, commit the lockfile, and use frozen installs in CI. The rest is detail.

Summary

  • All four tools run the same pipeline — read package.json, resolve ranges to concrete versions, fetch tarballs to a cache, write a lockfile, lay files out under node_modules. They differ mainly in that last step.
  • npm builds a flat, hoisted node_modules. Shared deps stay single, but version conflicts force nested duplicates, and hoisting exposes phantom dependencies — packages you can import without declaring, which break silently later.
  • pnpm (the 2026 default) keeps one content-addressed store per machine, hard-links it into node_modules, and symlinks only your declared deps at the top level. Big disk savings and no phantom deps, by construction.
  • Yarn Berry uses Plug’n’Play — no node_modules, a single .pnp.cjs graph, strict resolution, and optional Zero-Installs. Fastest to strictness, at some tooling-compatibility cost.
  • Bun ships the fastest installer, with a hoisted layout and a text-based bun.lock since Bun 1.2. Optimizes speed, not strictness.
  • Commit the lockfile; use frozen installs in CI (npm ci, pnpm install --frozen-lockfile, yarn install --immutable, bun install --frozen-lockfile). Pin the tool itself with Corepack.
  • Overrides / resolutions force a transitive version — a scalpel for security patches, not a default. dlx/npx/bunx run one-off CLIs without installing them.