Skip to content

npm vs pnpm, and workspaces

npm, pnpm, and Yarn all read package.json and fill node_modules. They differ in how they lay out that directory, and that layout decides whether your code can import packages you never declared, how much disk you burn, and how fast installs are.

npm 1 and 2 nested dependencies literally: node_modules/a/node_modules/b/node_modules/c. That produced paths too long for Windows and enormous duplication, so npm 3 introduced hoisting — pull every package up to the top level unless a version conflict forces nesting.

your-app/
node_modules/
express/ <- you asked for this
body-parser/ <- you did NOT; express depends on it
cookie/ <- nor this
debug/ <- nor this
ms/ <- nor this (debug depends on it)
... ~60 more

Because module resolution walks up looking for a node_modules directory and takes the first match, your code can now require('ms') successfully — even though ms appears nowhere in your package.json.

That is a phantom dependency: a package you import but never declared. It works today and breaks in three ways:

  1. A transitive dependency changes. Express drops ms, or upgrades it to a major with a different API. Your code breaks with no change on your side and nothing in your dependency diff to explain it.
  2. A different resolver. Install with pnpm, or with a bundler that enforces declarations, and the import fails immediately.
  3. You publish it. Your library imports a package it does not list, so consumers get MODULE_NOT_FOUND while your own tests pass.

The failure is silent, delayed, and hard to trace, which is what makes it worth designing against.

Hoisting deduplicates only when versions are compatible. A tree that needs lodash@4.17.21 in two places and lodash@3.10.1 in a third gets multiple full physical copies. Across projects it is worse: ten repos on your machine that all use TypeScript store ten complete copies of TypeScript. node_modules reaching several gigabytes per machine is normal.

Hoisting order depends on the order packages are processed. Two installs producing the same versions can still produce different layouts, and therefore different phantom-import behaviour. The lockfile pins versions; it historically did less to pin structure.

pnpm (“performant npm”) keeps a single content-addressable store on disk and builds node_modules from links into it.

Terminal window
pnpm store path
# ~/.local/share/pnpm/store/v10 (Linux; differs per OS)

Three mechanisms:

  1. The global store. Every package version is downloaded once per machine and stored, addressed by the hash of its contents.
  2. Hard links. Files in node_modules are hard links to store files — the same inode, so a second project referencing the same version costs essentially zero extra bytes. Because they are hard links, not copies, the file exists once on disk regardless of how many projects “contain” it.
  3. A symlinked tree. node_modules/<pkg> is a symlink into a hidden real store of packages at node_modules/.pnpm/.
node_modules/
.pnpm/
express@4.19.2/node_modules/
express/ <- hard links to the store
body-parser -> ../../body-parser@1.20.2/node_modules/body-parser
cookie -> ../../cookie@0.6.0/node_modules/cookie
body-parser@1.20.2/node_modules/body-parser/
...
express -> .pnpm/express@4.19.2/node_modules/express

Only express — the package you declared — is symlinked at the top level. Its own dependencies are symlinked next to it inside .pnpm, so Node’s upward search finds them from Express’s code but not from yours.

The result: require('ms') from your code fails, because ms is not in your top-level node_modules. Phantom dependencies become an install-time error instead of a production surprise. This is pnpm’s central design decision; everything else follows from it.

  • Some tools assume flat. Older bundlers, React Native’s Metro, and a few frameworks resolve paths in ways that break on symlinks. node-linker=hoisted in .npmrc makes pnpm produce an npm-style flat tree while keeping the store.
  • Packages with undeclared dependencies fail. That is the point, but it does mean some published packages simply do not install cleanly. public-hoist-pattern[]=*eslint* (or shamefully-hoist=true, which hoists everything) works around it.
  • Hard links need the store on the same filesystem. Across a Docker volume boundary pnpm falls back to copying.
  • Yarn 1 (“Classic”) — flat node_modules, same phantom-dependency model as npm, plus a yarn.lock. Maintenance-only, but still widely deployed.
  • Yarn 2+ (“Berry”) — introduced Plug’n’Play: no node_modules at all. Dependencies stay as zip archives and a generated .pnp.cjs resolver tells Node exactly where each import lives. Strict, fast, and by far the biggest compatibility jump. Berry can also run nodeLinker: node-modules for a conventional layout, which is what most teams pick.
npm pnpm Yarn Berry (PnP)
Layout Flat, hoisted Symlinks + global store Zips + resolver, no node_modules
Phantom deps possible Yes No No
Disk per extra project Full copy ~0 (hard links) ~0 (shared cache)
Cold install Slowest Fast Fast
Warm install Moderate Fastest Fast
Lockfile package-lock.json pnpm-lock.yaml yarn.lock
Ships with Node Yes No No
Ecosystem compatibility Total Very high Good, occasional friction
Terminal window
npm install -g pnpm
# or via Corepack, which ships with Node (experimental):
corepack enable
corepack use pnpm@latest # also writes "packageManager" into package.json

Pinning packageManager in package.json means every contributor and CI job uses the same client version:

package.json
{ "packageManager": "pnpm@9.12.0" }
Task npm pnpm
Install everything npm install pnpm install
Frozen / CI install npm ci pnpm install --frozen-lockfile
Add a dependency npm install pkg pnpm add pkg
Add a dev dependency npm install -D pkg pnpm add -D pkg
Add exact version npm install -E pkg pnpm add -E pkg
Add globally npm install -g pkg pnpm add -g pkg
Remove npm uninstall pkg pnpm remove pkg
Run a script npm run build pnpm build (or pnpm run build)
Pass args to a script npm run test -- --watch pnpm test --watch
Run a local binary npx eslint . pnpm exec eslint .
Run a remote binary npx create-vite pnpm dlx create-vite
What’s outdated npm outdated pnpm outdated
Update in range npm update pnpm update
Interactive update pnpm update -i --latest
Why is this installed npm explain pkg pnpm why pkg
Audit npm audit pnpm audit
Prune the cache/store npm cache clean --force pnpm store prune

Two ergonomic differences worth internalising:

  • pnpm build works without run, as long as the name does not collide with a built-in pnpm command. When in doubt, pnpm run build.
  • pnpm does not need -- before script arguments. pnpm test --watch passes --watch straight through.

A monorepo holds several packages in one repository. Workspaces are the package manager feature that makes that work: one install at the root, one lockfile, and cross-package links resolved to your local source rather than the registry.

my-monorepo/
package.json
packages/
core/ package.json (name: @acme/core)
cli/ package.json (name: @acme/cli, depends on @acme/core)
apps/
web/ package.json (name: @acme/web)

Editing packages/core is immediately visible to packages/cli — no publishing, no npm link, no build-and-copy loop.

Declare them in the root package.json (npm 7+):

package.json
{
"name": "my-monorepo",
"private": true,
"workspaces": ["packages/*", "apps/*"]
}

npm install at the root installs every workspace’s dependencies into a shared root node_modules, and symlinks each workspace package into it by name.

Terminal window
npm install # install everything, everywhere
npm install lodash -w @acme/core # add a dep to one workspace
npm install -D typescript # add to the ROOT (shared tooling)
npm run build -w @acme/core # run a script in one workspace
npm run build --workspaces # run it in every workspace that has it
npm run build --workspaces --if-present # skip those that don't have it
npm exec -w @acme/cli -- eslint . # run a binary inside one workspace

Reference a sibling like any other dependency; npm links the local copy when the version matches:

packages/cli/package.json
{
"dependencies": { "@acme/core": "^1.0.0" }
}

pnpm uses a separate file at the repo root:

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

And the workspace: protocol, which is explicit about “this must be the local one”:

packages/cli/package.json
{
"dependencies": {
"@acme/core": "workspace:*"
}
}

workspace:* means “always link the local package, whatever its version”. On pnpm publish, pnpm rewrites it to the real version number, so consumers get a normal dependency. workspace:^ publishes as ^<version>.

--filter is pnpm’s strongest monorepo feature — it selects packages by name, path, or graph relationship.

Terminal window
pnpm --filter @acme/core build # just that package
pnpm --filter "./apps/*" build # by directory glob
pnpm --filter @acme/core... build # core AND its dependencies (trailing ...)
pnpm --filter ...@acme/core build # core AND its dependents (leading ...)
pnpm --filter @acme/web^... build # only web's dependencies, not web itself
pnpm --filter "[origin/main]" test # only packages changed since a git ref
pnpm --filter "...[origin/main]" test # changed packages AND everything downstream
pnpm -r build # recursive: every package, in topological order
pnpm -r --parallel dev # run long-lived dev servers together

The ellipsis position is the thing to memorise: trailing ... walks toward dependencies, leading ... walks toward dependents.

pnpm -r build respecting topological order matters: @acme/core builds before @acme/cli because the dependency graph says so. --filter "...[origin/main]" — changed packages plus everything that depends on them — is the standard CI pattern: build and test only what a pull request could have affected.

Adding a dependency in a workspace:

Terminal window
pnpm add lodash --filter @acme/core # to one package
pnpm add -Dw typescript # to the workspace ROOT (-w)
  • Version drift on shared tooling. Two workspaces on different TypeScript majors will produce baffling errors. Keep build tooling in the root devDependencies.
  • Peer dependencies across workspaces. React must be a single copy; declare it as a peer in libraries and a real dependency only in the app.
  • Build order. Running test before dependencies are built is the classic broken CI pipeline. Use pnpm -r (topological) or a task runner like Turborepo or Nx that adds caching and dependency-aware task graphs on top.
  • Publishing. Tools like Changesets handle per-package versioning, changelogs, and coordinated releases.
Situation Pick
A small app or script, one package npm. It is already installed and there is nothing to gain.
A monorepo of any size pnpm. Filtering, strictness, and disk savings compound with package count.
Many projects on one machine, or CI with a warm cache pnpm. The shared store is the whole point.
A published library where correct dependency declarations are critical pnpm. Its strictness catches phantom imports before your users do.
A repo already on Yarn and working Stay. Migration cost rarely beats the benefit.
Tooling that breaks on symlinks (some React Native setups) npm, or pnpm with node-linker=hoisted.

Whatever you choose, choose one. Mixing clients produces two lockfiles that disagree, and the resulting bugs are only reproducible on whichever machine used the other one.

.gitignore
node_modules/
# and commit exactly one of:
# package-lock.json | pnpm-lock.yaml | yarn.lock
  • npm’s flat, hoisted node_modules lets you import undeclared packages — phantom dependencies that break later, elsewhere.
  • pnpm stores each package version once per machine and hard-links it, so extra projects cost almost no disk.
  • pnpm’s symlinked layout exposes only your declared dependencies at the top level, turning phantom imports into immediate errors.
  • Yarn Berry’s PnP removes node_modules entirely; most teams run it in node-modules mode.
  • Workspaces = one install, one lockfile, local packages linked instead of downloaded.
  • workspace:* guarantees you get the local copy; pnpm rewrites it on publish.
  • pnpm --filter pkg... and --filter "[origin/main]" are how you keep monorepo CI fast.
  • One package manager per repo, one lockfile, always committed.