npm vs Yarn vs pnpm: Choosing the Right JavaScript Package ManagerA deep technical comparison of the three major Node.js package managers - from dependency resolution and disk usage to monorepo support and CI performance

Introduction

Every JavaScript project starts with the same quiet assumption: that the tools managing your dependencies are doing the right thing. You run npm install, a node_modules folder materializes, and the project works. For years, this was the whole story. But as frontend and full-stack codebases have grown in complexity - monorepos with dozens of workspaces, CI pipelines that live or die by cache hit rates, teams that span continents - the package manager you choose has become a genuine architectural decision.

npm, Yarn, and pnpm each emerged from different frustrations with the status quo. Yarn appeared in 2016 because npm was too slow and non-deterministic. pnpm followed because even Yarn's improvements hadn't solved the deeper problem of wasted disk space and phantom dependencies. npm itself has absorbed lessons from both rivals and shipped substantial improvements in v7 and v8. Understanding these tools at a mechanical level - not just benchmarks, but how they model the dependency graph, write files to disk, and enforce correctness - puts you in a position to make a principled choice rather than a habitual one.

This article is aimed at engineers who already know how to use a package manager and want to understand what's actually happening underneath. It covers dependency resolution algorithms, the content-addressable store, lockfile semantics, workspace protocols, and the trade-offs that matter in real production environments.

A Brief History of the Problem

When npm launched alongside Node.js in 2010, it introduced a simple model: a registry of versioned packages, a package.json manifest, and a recursive install that pulled transitive dependencies into a nested node_modules tree. This worked, mostly. But the nested model created deeply nested paths that broke Windows filesystem limits, and each project maintained its own full copy of every package - even if lodash@4.17.21 appeared in fifty projects on the same machine.

The more structural problem was non-determinism. Without a lockfile, two developers running npm install on the same package.json at different times could get different versions of transitive dependencies. The ^ semver operator means "compatible with", and "compatible" is a moving target as packages publish patches and minor versions. In 2016 this caused real incidents: production deploys that diverged from local environments because the CI server installed on a different day.

Yarn 1 (Classic) answered this with yarn.lock, a deterministic lockfile format, and a flat install algorithm that hoisted packages as high as possible in the tree to eliminate redundant copies. It was also dramatically faster than npm at the time, thanks to parallel fetching and a local cache. Yarn set new expectations for what a package manager should do, and npm responded: lockfiles became standard (package-lock.json in npm v5), and performance improved significantly in later releases.

pnpm took a more radical stance. Rather than trying to flatten the dependency graph, it asked: what if every package version were stored exactly once on the machine, and projects accessed it via a content-addressable store with symlinks? This eliminated disk waste by design and enforced stricter access control over the dependency graph. Yarn v2 (Berry) went in a different direction entirely, introducing Plug'n'Play (PnP) to eliminate node_modules completely. Both represent genuine architectural rethinks, not just performance tweaks.

Dependency Resolution: How Each Tool Builds the Graph

This is the most consequential difference between the three tools, and it's worth understanding at a mechanical level.

npm: Flat Hoisting with Nested Fallback

npm resolves dependencies by building a tree and then hoisting packages as high as possible - a process sometimes called "maximally flat" installation. If two packages depend on different versions of react, npm attempts to hoist one version to the root and nest the other inside the package that needs it. The algorithm is defined in the npm documentation as producing a tree where each package appears at the highest level where it doesn't conflict with an existing install.

The practical consequence is that packages in your project can require() modules they never explicitly declared as dependencies. If your code does const _ = require('lodash') and lodash happens to be hoisted to the root because some transitive dependency installed it, your code works - until it doesn't, because that transitive dependency updates and removes lodash, or pins a different version. This is the "phantom dependency" problem. It's a correctness issue disguised as convenience.

Yarn Classic: The Same Model, Faster

Yarn Classic (v1) uses essentially the same flat hoisting algorithm as npm, which is not a coincidence - it was designed as a drop-in replacement. The differences were in execution: parallel network requests, a global cache so packages aren't re-downloaded per project, and the deterministic lockfile. The dependency graph structure on disk is largely the same as npm's, which meant projects could switch between them without changes to their code.

Yarn Berry (v2+) represents a complete rewrite. Its default mode, Plug'n'Play, abandons node_modules entirely. Instead of writing packages to the filesystem in a hoisted structure, it maintains a single .yarn/cache directory of zip archives and generates a .pnp.cjs file - a JavaScript module that maps every require() call to its exact resolution. Node's module resolution is intercepted at startup, and packages never need to be unpacked from their zips. This makes installs extremely fast (no filesystem writes beyond the cache) and enforces strict dependency declarations: if your code tries to require something not in your declared dependencies, it fails with a clear error.

pnpm: Content-Addressable Store with Symlink Isolation

pnpm's model is architecturally distinct from both. When you install packages, they are stored in a global content-addressable store, typically ~/.pnpm-store. Each unique package@version is stored once, as a set of files indexed by content hash. When a project installs a package, pnpm creates a node_modules/.pnpm directory with hard links to the store. The package then appears at the expected node_modules/package-name path via a symlink.

The critical consequence is that each package only sees its own declared dependencies in its node_modules. pnpm creates an isolated virtual store where the symlink structure enforces the dependency graph. If package-a depends on lodash, lodash appears in node_modules/.pnpm/package-a@x.y.z/node_modules/lodash, linked to the central store. The root node_modules only contains packages declared in the root package.json. Phantom dependencies become impossible by default.

Lockfiles, Determinism, and Reproducibility

Determinism - the property that the same inputs always produce the same outputs - is fundamental to reliable software. In package management, it means two developers on different machines, or a developer and a CI server, installing the same package.json get byte-identical node_modules.

npm's package-lock.json records the exact resolved versions and integrity hashes for the entire dependency tree. It is generated automatically and should be committed to version control. npm v7 introduced a v2 lockfile format that includes both packages and dependencies fields, maintaining backward compatibility while adding richer metadata. The npm ci command was introduced specifically for deterministic installs in CI: it ignores package.json version ranges entirely and installs exactly what's in the lockfile, failing if the two are out of sync.

Yarn's yarn.lock is a custom format (not JSON) that records resolved versions and checksums. Yarn Berry generates yarn.lock in a somewhat different internal structure. Both formats are designed to be committed to version control and produce deterministic installs. Yarn's --immutable flag, the equivalent of npm ci, will fail if the lockfile would need to be updated, making it suitable for CI environments where lockfile drift should be a hard error.

pnpm's pnpm-lock.yaml is arguably the most human-readable of the three - it uses YAML and records the full dependency graph with import specifiers and integrity hashes. pnpm also enforces a stricter model: the lockfile is never auto-corrected silently. If package.json and pnpm-lock.yaml are out of sync, pnpm reports an error unless you explicitly run pnpm install to update the lockfile. The --frozen-lockfile flag provides CI-safe immutable installs.

An important subtlety: lockfile formats are not interchangeable. If a project uses package-lock.json, running yarn install generates a yarn.lock that may resolve slightly differently, particularly for packages that use non-standard resolution or resolutions/overrides fields. Teams switching package managers should regenerate the lockfile from scratch and verify the resulting tree matches expected behavior.

Disk Efficiency and the Content-Addressable Store

On a developer machine with dozens of Node.js projects, disk usage from node_modules can easily reach tens of gigabytes. Each project maintains its own full copy of common packages like TypeScript, ESLint, and their plugins, even if every project uses the same version.

pnpm's global store eliminates this redundancy through hard links. A hard link is a directory entry that points to the same inode - the same underlying data on disk - as another entry. When two projects both depend on typescript@5.3.3, they share the same files via hard links to the store rather than maintaining two physical copies. The disk usage for that package is counted once. On a machine with many Node.js projects, this can reduce total node_modules disk usage by 60-80%. The store itself is maintained at ~/.local/share/pnpm/store on Linux/macOS, and pnpm provides pnpm store prune to remove packages no longer referenced by any project.

Yarn Berry's zip-based cache achieves similar deduplication differently. Packages are stored as zip archives in a shared .yarn/cache directory (typically committed to the repository in zero-installs mode, or stored globally). Because packages aren't extracted into node_modules, the cache entry is the compressed zip rather than thousands of individual files. This dramatically reduces the number of filesystem inodes consumed, which matters on systems with inode limits, and speeds up cold installs on network filesystems.

npm's model has no such deduplication. Each project has a full physical copy of every package. npm does have a local cache (~/.npm/_cacache) that speeds up downloads by avoiding re-fetches, but it caches tarballs and unpacked content, not the final installed files. Two projects with the same version of a package still maintain separate physical copies on disk.

Monorepo and Workspace Support

The JavaScript ecosystem has converged on monorepos for large-scale projects - single repositories containing multiple packages or applications managed together. All three package managers support "workspaces", a protocol for linking packages within a monorepo and running scripts across them. But the implementations differ significantly in ergonomics and capability.

npm Workspaces, introduced in v7, provides the foundational mechanics: declaring workspace packages in the root package.json, hoisting shared dependencies to the root, and running commands in individual workspaces via --workspace. It works, but the tooling is sparse. There's no built-in topological task runner (running build in dependency order), no per-workspace patching, and filtering capabilities are more limited than the alternatives.

Yarn Berry's workspace support is more mature, particularly around protocol handling. The workspace: protocol lets you declare a dependency on another package in the monorepo as "my-package": "workspace:*", and Yarn resolves it to the local version, rewriting the version specifier correctly when publishing. The yarn workspaces foreach command supports parallel execution and topological ordering. Yarn also introduced "constraints" - a Prolog-based system for enforcing rules across all workspace package.json files, which is unusual but genuinely powerful for large organizations maintaining consistency at scale.

pnpm's workspace support is widely regarded as the most capable of the three. The workspace: protocol works similarly to Yarn's. pnpm's filter syntax is expressive: pnpm --filter ...^my-package build runs the build script in all packages that depend on my-package, which is invaluable for incremental CI pipelines. pnpm also supports "catalog" in recent versions - a way to define shared version constraints for dependencies across all workspace packages, avoiding version drift. For teams using Turborepo or Nx as task orchestrators layered on top of a package manager, pnpm's workspace protocol and filtering are well-supported first-class inputs.

// pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'

// packages/ui/package.json
{
  "name": "@myorg/ui",
  "version": "1.0.0",
  "dependencies": {
    "react": "catalog:"
  }
}

// Root package.json with pnpm catalog
{
  "pnpm": {
    "catalog": {
      "react": "^18.3.0"
    }
  }
}

Performance: Install Speed in Practice

Raw install speed depends on the scenario: cold install (no cache), warm install (cache populated, no lockfile changes), and CI install (cache restored from artifact). The numbers vary by project size and network conditions, but the general patterns are consistent.

For cold installs, pnpm is typically the fastest because its hard-link model means that once a package version is in the global store, installing it in a new project is a filesystem operation - creating hard links - rather than a network operation. Yarn Berry with zero-installs is not meaningfully a "cold install" at all, since the cache is committed to the repository. npm cold installs are generally the slowest of the three, though improvements in v8 have narrowed the gap.

For warm installs with no dependency changes, all three are fast. This is the scenario developers experience most often - running npm install after a git pull that didn't touch package.json. pnpm is still typically faster because it can verify the existing node_modules structure by checking hard link integrity rather than re-extracting tarballs. Yarn PnP installs are essentially a no-op if the lockfile hasn't changed, since there's nothing to write to disk.

CI performance deserves special attention because it's often the bottleneck in developer velocity. The key variable is whether the cache is restored between runs. With proper cache configuration, pnpm's global store or Yarn's cache directory can be restored from a CI artifact, and subsequent installs are near-instant. Without cache, all three tools hit the network. Teams investing in CI optimization should measure their specific dependency tree - generalizing from benchmarks built on small demo projects is unreliable.

# GitHub Actions: pnpm with global store caching
- uses: pnpm/action-setup@v4
  with:
    version: 9

- uses: actions/setup-node@v4
  with:
    node-version: "20"
    cache: "pnpm"

- run: pnpm install --frozen-lockfile

Phantom Dependencies and the Ghost in node_modules

The phantom dependency problem is worth dwelling on because it is the most insidious correctness issue in npm/Yarn Classic environments, and it bites production systems in ways that are hard to diagnose.

Consider a project with a direct dependency on express. Express depends on debug. After hoisting, debug appears at the root of node_modules. Your application code - or worse, some utility library someone added years ago - does const debug = require('debug') without declaring it as a direct dependency. The code works. Then express updates to a new major version that no longer depends on debug, or pins a different major version. Suddenly require('debug') in your code resolves to a different version, or fails to resolve at all. The failure mode is non-local and non-obvious: the bug looks like it's in code that hasn't changed.

This pattern is endemic in large JavaScript codebases that have grown organically. It's particularly common with development tooling - @types/* packages, babel plugins, and eslint plugins are frequently required transitively and accessed directly without being declared.

pnpm's isolated node_modules makes phantom dependencies a hard error rather than a latent bug. If your code requires a package not in your package.json, the require call fails immediately because the package doesn't appear in your package's virtual node_modules. This strictness is initially jarring when migrating an existing project - you discover phantom dependencies you didn't know you had. But the discipline it enforces pays dividends in long-term maintainability. Yarn PnP enforces the same correctness guarantee by design, since all resolutions go through the .pnp.cjs loader which only knows about declared dependencies.

Teams migrating from npm or Yarn Classic to pnpm can use pnpm install --shamefully-hoist as a transitional flag that restores the flat hoisting behavior, allowing the project to work while phantom dependencies are identified and resolved systematically. The goal is to remove that flag entirely.

Practical Configuration and Migration

Choosing a package manager is not purely a technical decision - it's also an operational one. Your team's existing tooling, your CI infrastructure, and the friction of migration all matter.

Switching from npm to pnpm is the most commonly recommended migration path for teams prioritizing correctness and disk efficiency. The steps are: install pnpm globally (npm install -g pnpm), run pnpm import to generate pnpm-lock.yaml from package-lock.json, delete node_modules and package-lock.json, run pnpm install, and fix any phantom dependency errors. Most projects complete this in a few hours for a single package, longer for monorepos.

Adopting Yarn Berry requires a more significant commitment. The .pnp.cjs file and zip-based cache are unfamiliar to many developers, and some tools (native Node.js addons, certain build systems) have historically had trouble with PnP's module resolution interception. Yarn provides a node-modules linker as a compatibility mode, which uses the traditional node_modules structure while still benefiting from Yarn's other features. Most major tools have added PnP support over the past three years, but it's worth auditing your toolchain before committing.

Staying on npm is a perfectly defensible choice for new projects or teams with simple dependency graphs and no monorepo requirements. npm v10 is competent, well-documented, and ships with Node.js, meaning zero additional tooling to manage. For teams that primarily care about not introducing new tools, npm is the path of least resistance.

# Enforce a specific package manager via packageManager field (Node.js Corepack)
# In package.json:
{
  "packageManager": "pnpm@9.1.0"
}

# Enable Corepack (ships with Node.js >= 16.9)
corepack enable

# Now running `npm install` in this repo will error;
# only `pnpm install` is permitted

The packageManager field in package.json, combined with Corepack (which ships with Node.js >= 16.9), is the correct way to enforce a specific package manager version across a team. It prevents the subtle bugs that arise when developers or CI runners use different package manager versions, and it makes the tooling requirement explicit and machine-verifiable.

Trade-offs and When Each Tool Excels

No package manager is universally superior. The right choice depends on your constraints.

npm is appropriate when you want to minimize toolchain surface area, your project is a single package without complex monorepo requirements, and you're on a Node.js version that ships a modern npm. It's also the obvious default for open source libraries where contributors may not have Yarn or pnpm installed - though Corepack mitigates this.

Yarn Berry (PnP mode) excels in large organizations that want zero-installs (the cache committed to the repository eliminates the install step entirely in CI) and are willing to invest in the initial migration. It's also the right choice if you want to enforce strict dependency correctness without relying on filesystem isolation, or if you want workspace constraints for cross-package policy enforcement. The trade-off is tooling compatibility - PnP still occasionally requires workarounds for older tools.

pnpm is the best default for new projects and monorepos in 2024. Its correctness guarantees, disk efficiency, and workspace ergonomics are superior to npm without requiring the tooling investment of Yarn Berry. Its adoption has grown significantly in the ecosystem, with major frameworks like Astro, Turborepo, and many others using it as the recommended package manager.

The following table summarizes the key dimensions:

DimensionnpmYarn ClassicYarn Berry (PnP)pnpm
Phantom dependenciesPossiblePossiblePreventedPrevented
Disk deduplicationNoneNoneZip cacheHard links
Monorepo workspacesBasicGoodGoodExcellent
CI zero-installsNoNoYesPartial (store cache)
Tooling compatibilityBestGoodRequires setupGood
Node.js built-inYesNoNoNo

Best Practices

Regardless of which package manager you choose, certain practices apply universally to maintain a healthy, reproducible dependency graph.

Always commit your lockfile to version control. This includes package-lock.json, yarn.lock, and pnpm-lock.yaml. A lockfile committed without the corresponding node_modules is the correct setup - node_modules should always be in .gitignore. Never commit node_modules. In CI, always use the immutable install flag (npm ci, pnpm install --frozen-lockfile, yarn install --immutable) so that a drift between package.json and the lockfile produces a hard failure rather than a silent re-resolution.

Use the packageManager field in package.json with Corepack to enforce the package manager and version across the entire team. This is a one-line change that eliminates an entire class of "works on my machine" issues. Pin the patch version, not just the minor, since patch releases can change resolution behavior in ways that affect reproducibility.

Audit your dependency tree regularly. Tools like npm audit, yarn npm audit, and pnpm audit check for known vulnerabilities in your dependency graph. More importantly, periodically examine your direct dependencies: many production incidents are caused not by vulnerabilities in well-known packages but by transitive dependencies of small, unmaintained utility packages. The license-checker package and similar tools can surface licensing risks in the transitive graph.

For monorepos, establish a clear policy on how dependency versions are managed across workspaces. pnpm's catalog feature, Yarn's resolutions field, and npm's overrides field all allow you to force a specific version of a transitive dependency across the entire workspace. Use this capability to keep the transitive graph coherent, but audit it regularly - overrides can mask legitimate peer dependency conflicts.

Finally, consider the total cost of your package manager choice, not just install speed. The most important metrics are developer velocity (how often does the package manager create friction or confusion?), CI reliability (how often does a failed install block a merge queue?), and long-term maintainability (how easy is it to audit, update, and reason about the dependency graph?). A 30% faster install that introduces phantom dependency bugs is not a win.

Key Takeaways

Five things you can apply immediately after reading this article:

  1. Add "packageManager" to your package.json and enable Corepack. This single change enforces the exact package manager version for everyone working on the project and eliminates an entire class of environment inconsistencies. Run corepack enable in your setup documentation.

  2. Use the immutable install flag in all CI pipelines. Replace npm install with npm ci, yarn install with yarn install --immutable, and pnpm install with pnpm install --frozen-lockfile. This turns lockfile drift from a silent failure into a visible one.

  3. Audit your codebase for phantom dependencies before your next major dependency update. Run pnpm install --shamefully-hoist (if migrating) or use eslint-plugin-import with the no-extraneous-dependencies rule to identify packages being required without being declared.

  4. For new monorepos, use pnpm with a pnpm-workspace.yaml and enable the catalog feature. Define shared versions of React, TypeScript, and your major shared dependencies in the catalog to prevent version drift across packages.

  5. Establish a lockfile update policy. Decide whether lockfile updates require a separate PR and review, or whether they're acceptable inline with dependency-change PRs. Document this in your contributing guide. Unreviewed lockfile updates are a supply chain risk.

80/20 Insight

Most of the practical benefit from switching package managers comes from two things, not ten. First: eliminating phantom dependencies, which pnpm and Yarn Berry both enforce by design. Second: committing the lockfile and using immutable installs in CI. Everything else - faster raw install speed, disk deduplication, workspace filters - is additive but secondary. If your team isn't doing these two things with npm, switching to pnpm won't solve your reproducibility problems. If you are doing them with npm, your pain threshold for switching should be higher than many blog posts suggest.

The key architectural insight is that node_modules is an implementation detail, not a contract. The real contract is the resolved dependency graph expressed in the lockfile. Any tool that makes the lockfile more authoritative, more auditable, and more deterministic is moving in the right direction - and all three major package managers have been moving in that direction, at different speeds, for the past several years.

Conclusion

The choice between npm, Yarn, and pnpm is no longer primarily about raw install speed - the performance differences between them on warm caches are small enough to be irrelevant to most teams. What differentiates them today is correctness, ergonomics at scale, and the mental model they impose on your dependency graph.

npm is the safe default, well-integrated with Node.js and suitable for projects that don't need workspace complexity or strict dependency isolation. Yarn Berry is the right tool for organizations willing to invest in a fundamentally different module resolution model in exchange for zero-installs and the strongest possible correctness guarantees. pnpm occupies the pragmatic middle ground: better correctness than npm through symlink isolation, better disk efficiency through hard links, and better monorepo ergonomics than either, without requiring a wholesale change to how Node.js resolves modules.

For most teams starting a new project in 2024, pnpm is the recommendation. For teams maintaining existing projects, the migration calculus depends on your specific pain points. If phantom dependencies and flaky CI installs are real problems, the investment in migration pays off quickly. If your current setup is stable and well-managed, the opportunity cost of migration may not be worth it.

What matters most is treating your package manager as a first-class part of your engineering infrastructure: pinning its version, enforcing immutable installs in CI, reviewing lockfile changes, and auditing the transitive dependency graph. These practices compound over time in ways that raw install speed does not.

References