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.
The problem with a flat node_modules
Section titled “The problem with a flat node_modules”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 moreBecause 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.
Phantom dependencies
Section titled “Phantom dependencies”That is a phantom dependency: a package you import but never declared. It works today and breaks in three ways:
- 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. - A different resolver. Install with pnpm, or with a bundler that enforces declarations, and the import fails immediately.
- You publish it. Your library imports a package it does not list, so consumers get
MODULE_NOT_FOUNDwhile your own tests pass.
The failure is silent, delayed, and hard to trace, which is what makes it worth designing against.
Duplication and disk
Section titled “Duplication and disk”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.
Non-determinism
Section titled “Non-determinism”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.
How pnpm works
Section titled “How pnpm works”pnpm (“performant npm”) keeps a single content-addressable store on disk and builds node_modules from links into it.
pnpm store path# ~/.local/share/pnpm/store/v10 (Linux; differs per OS)Three mechanisms:
- The global store. Every package version is downloaded once per machine and stored, addressed by the hash of its contents.
- Hard links. Files in
node_modulesare 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. - A symlinked tree.
node_modules/<pkg>is a symlink into a hidden real store of packages atnode_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/expressOnly 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.
Costs and escape hatches
Section titled “Costs and escape hatches”- Some tools assume flat. Older bundlers, React Native’s Metro, and a few frameworks resolve paths in ways that break on symlinks.
node-linker=hoistedin.npmrcmakes 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*(orshamefully-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, briefly
Section titled “Yarn, briefly”- Yarn 1 (“Classic”) — flat
node_modules, same phantom-dependency model as npm, plus ayarn.lock. Maintenance-only, but still widely deployed. - Yarn 2+ (“Berry”) — introduced Plug’n’Play: no
node_modulesat all. Dependencies stay as zip archives and a generated.pnp.cjsresolver tells Node exactly where each import lives. Strict, fast, and by far the biggest compatibility jump. Berry can also runnodeLinker: node-modulesfor a conventional layout, which is what most teams pick.
Comparison
Section titled “Comparison”| 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 |
Installing pnpm
Section titled “Installing pnpm”npm install -g pnpm# or via Corepack, which ships with Node (experimental):corepack enablecorepack use pnpm@latest # also writes "packageManager" into package.jsonPinning packageManager in package.json means every contributor and CI job uses the same client version:
{ "packageManager": "pnpm@9.12.0" }Command cheat sheet
Section titled “Command cheat sheet”| 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 buildworks withoutrun, 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 --watchpasses--watchstraight through.
Monorepos and workspaces
Section titled “Monorepos and workspaces”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.
npm workspaces
Section titled “npm workspaces”Declare them in the root package.json (npm 7+):
{ "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.
npm install # install everything, everywherenpm install lodash -w @acme/core # add a dep to one workspacenpm install -D typescript # add to the ROOT (shared tooling)npm run build -w @acme/core # run a script in one workspacenpm run build --workspaces # run it in every workspace that has itnpm run build --workspaces --if-present # skip those that don't have itnpm exec -w @acme/cli -- eslint . # run a binary inside one workspaceReference a sibling like any other dependency; npm links the local copy when the version matches:
{ "dependencies": { "@acme/core": "^1.0.0" }}pnpm workspaces
Section titled “pnpm workspaces”pnpm uses a separate file at the repo root:
packages: - 'packages/*' - 'apps/*' - '!**/test/**'And the workspace: protocol, which is explicit about “this must be the local one”:
{ "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>.
Filtering with pnpm
Section titled “Filtering with pnpm”--filter is pnpm’s strongest monorepo feature — it selects packages by name, path, or graph relationship.
pnpm --filter @acme/core build # just that packagepnpm --filter "./apps/*" build # by directory globpnpm --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 itselfpnpm --filter "[origin/main]" test # only packages changed since a git refpnpm --filter "...[origin/main]" test # changed packages AND everything downstreampnpm -r build # recursive: every package, in topological orderpnpm -r --parallel dev # run long-lived dev servers togetherThe 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:
pnpm add lodash --filter @acme/core # to one packagepnpm add -Dw typescript # to the workspace ROOT (-w)Things that bite in any monorepo
Section titled “Things that bite in any monorepo”- 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
testbefore dependencies are built is the classic broken CI pipeline. Usepnpm -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.
Choosing
Section titled “Choosing”| 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.
node_modules/# and commit exactly one of:# package-lock.json | pnpm-lock.yaml | yarn.lockKey points
Section titled “Key points”- npm’s flat, hoisted
node_moduleslets 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_modulesentirely; most teams run it innode-modulesmode. - 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.