rgoussu@goussu: ~/library/frontend/build-ecosystem
~/library/frontend/build-ecosystem cat package-managers.md

Package managers: npm, pnpm, yarn, bun

# One registry, four clients — semver ranges and lockfiles, node_modules layouts from hoisted flat trees to pnpm's symlinked store, workspaces, Corepack pinning, and the lifecycle-script supply chain.

Conceptsaved 2026-08-10updated 2026-08-20 #typescript#javascript#build#tooling#npm#pnpm

Overview

All four clients install from the same npm registry — the largest package registry in existence — so, exactly as Maven Central sits under both Maven and Gradle, the substrate is shared and a package published once is consumable by every client. The choice is about the client: how it lays out node_modules, how strictly it isolates dependencies, how fast it installs, and what it does about the supply-chain surface of lifecycle scripts. npm is the baseline that ships with Node; pnpm is the correctness-and-speed choice most serious teams converge on; yarn pioneered half the ideas the others absorbed; bun is the raw-speed outlier.

Key points

  • One registry, four clients: no client has a separate package universe; scoped names (@acme/pkg) are the namespace mechanism, and private registries (Verdaccio, Artifactory, GitHub Packages) proxy or supplement the public one — the Maven Central/Nexus pattern, transplanted.
  • Semver ranges + lockfiles: manifests declare ranges (^1.2.3 — compatible minor/patch), the lockfile pins the fully resolved graph — package-lock.json, pnpm-lock.yaml, yarn.lock, bun.lock. Commit it; CI installs with npm ci / pnpm install --frozen-lockfile so the lockfile is authority, not hint.
  • The hoisted flat tree (npm, yarn, bun): dependencies are deduplicated by hoisting to top-level node_modules — which makes every hoisted package importable, declared or not. These phantom dependencies work until an unrelated install reshuffles the tree; the layout's original sin.
  • pnpm's layout is the fix: every package version lives once in a global content-addressable store, hard-linked into the project's virtual store, with node_modules built from symlinks so only declared dependencies resolve. Strictness plus one-copy-per-version disk usage plus near-instant repeat installs.
  • Workspaces: npm's workspaces field or pnpm-workspace.yaml make one repo hold many packages; the workspace:* protocol (pnpm, yarn) pins an internal dependency to the local sibling and rewrites it to a real version range at publish time.
  • packageManager + Corepack: the manifest field pins client and exact version per repo — the mvnw/gradlew wrapper idea. Corepack reads it and fetches the right binary; its bundled-with-Node status has wobbled, but the field remains the pinning convention the clients themselves honour. It doubles as a provisioning source: CI can corepack enable && corepack install and get the exact client the repo declares, with no version stated in the pipeline at all — one fewer place to drift.
  • Corepack pins the client, nvm installs the runtime — neither covers the other, so provisioning a pnpm project needs both. See Version managers for why that pairing is a composition rather than a manager of its own.
  • Overrides and patching: npm overrides / yarn resolutions / pnpm.overrides force a transitive version (the vulnerable-dep escape hatch); pnpm patch goes further and maintains a committed diff applied to a dependency at install.
  • Lifecycle scripts are the supply-chain surface: postinstall runs arbitrary code from every package in the tree — the vector behind most registry attacks. pnpm 10 stopped running dependency scripts by default (onlyBuiltDependencies allowlists the few that genuinely compile natives); on npm that stance is opt-in --ignore-scripts.
  • The outliers, one line each: bun installs several times faster than anyone and is mostly drop-in; yarn Plug'n'Play abolishes node_modules entirely (imports resolved from zipped archives via a loader) — the strictest model, bought with ecosystem friction.

Details

The clients compared

Client Lockfile node_modules layout Distinguishing stance
npm package-lock.json Hoisted flat tree Ships with Node; the zero-decision baseline everything supports
pnpm pnpm-lock.yaml Symlinked strict layout over a content-addressable store Correctness (no phantoms) + disk/speed wins; dependency scripts off by default
yarn (Berry) yarn.lock Plug'n'Play, or hoisted via nodeLinker The ideas factory — PnP, constraints, patches — and the heaviest departure from the norm
bun bun.lock Hoisted flat tree Raw install speed; one piece of the Bun runtime's all-in-one toolkit

Why the pnpm store wins on disk and speed

A hard link is the same file under two names: fifty projects depending on the same lodash version hold fifty links to one store entry, not fifty copies. Installs that hit the store do no network and no copying — link and go. The symlink layer on top is what enforces strictness: a package's node_modules contains links to its declared dependencies only, so a phantom import fails at resolution instead of in production.

Examples

A workspace with an internal dependency, pnpm-style:

# pnpm-workspace.yaml — at the repo root
packages:
  - "apps/*"
  - "packages/*"
// apps/web/package.json — depends on a sibling package
{
  "name": "@acme/web",
  "dependencies": {
    "@acme/domain-core": "workspace:*"
  }
}

workspace:* always resolves to the local packages/domain-core; on publish it is rewritten to the sibling's real version, so consumers outside the repo see a normal semver range.

Related