Frontend / Build tools / 07_package_managers.md

Package Managers — npm vs pnpm vs yarn, Lockfile Semantics

Updated 7 min read source
On this page6
  1. TL;DR
  2. In depth
  3. Gotchas / edge cases
  4. What a senior is expected to say
  5. Cross-references
  6. Further reading

Package Managers — npm vs pnpm vs yarn, Lockfile Semantics

TL;DR

Three options. npm ships with Node — universal, slowest, fine. pnpm is the modern senior pick — fast, disk-efficient (content-addressable store), strict (catches phantom deps), good monorepo support. Yarn has two lives — Yarn Classic (v1, dying) and Yarn Berry (v2+, Plug’n’Play architecture, niche). For new projects in 2026: pnpm. The lockfile is non-negotiable — always commit, never delete to “fix” install issues, treat as source of truth for reproducible installs.

In depth

What’s actually different between them?

npm pnpm Yarn Classic Yarn Berry
Speed slow fast medium fast
Disk usage per-project full copy content-addressable global store + symlinks per-project full copy per-project zips (PnP)
Strict imports no (hoisted flat) yes (only declared deps) no yes (PnP)
Workspaces yes (v7+) yes (best) yes yes
Lockfile package-lock.json pnpm-lock.yaml yarn.lock yarn.lock
node_modules hoisted flat nested + symlinks hoisted flat none (PnP) — .yarn/cache
Plug-and-play no no no yes
Community largest growing rapidly shrinking small

Why is pnpm faster and disk-efficient?

Content-addressable global store. When you install react@18.2.0, pnpm:

  1. Checks if react@18.2.0 is already in the global store (~/.pnpm-store).
  2. If yes: creates hardlinks/symlinks from node_modules to the store.
  3. If no: downloads once to the store, then links.

Result: 10 projects depending on react@18.2.0 share one copy on disk. npm/Yarn classic create a full copy per project.

Disk savings on a dev machine with many projects: easily 5-10× over npm.

pnpm “strict” — what does that catch?

Phantom dependencies. With hoisted node_modules, your code might import lodash even though only an indirect dep brought it in. It works until the indirect dep updates and drops lodash → your code breaks “for no reason.”

pnpm’s nested + symlinked structure means your package can only resolve deps you explicitly declared. If you import lodash without listing it in package.json, the import fails immediately.

This catches a real bug class. The trade-off: some legacy packages that rely on phantom hoisting break. shamefully-hoist: true in .npmrc flattens deps to compensate; use sparingly.

What is Yarn Berry’s “Plug’n’Play”?

Yarn 2+ ditched node_modules entirely. Instead:

  • Deps are stored as zipped archives in .yarn/cache.
  • A .pnp.cjs manifest tells Node how to resolve any import to its location inside a zip.
  • Node runs with a custom loader that reads from the zips.

Pros: zero install time (zips are checked in via Git LFS or downloaded once), strict resolution, smaller repo (no node_modules).

Cons: every tool needs to understand PnP. Tools that hard-code “node_modules” paths break. The ecosystem fought back hard; adoption stalled.

For 2026: most teams use Yarn Classic if on Yarn, or moved to pnpm. Berry/PnP is niche.

Lockfile — what’s it for, why commit?

Records the exact versions of every package in the dep tree (transitive too).

Without a lockfile:

  • npm install lodash resolves to whatever version matches ^4.17.20 today.
  • Tomorrow it resolves differently. CI builds differ from yesterday’s; bug reports become unreproducible.

With a lockfile:

  • The lockfile pins lodash@4.17.21 (exact).
  • npm ci (or pnpm install --frozen-lockfile / yarn install --frozen-lockfile) installs exactly what’s in the lockfile.
  • Same versions everywhere — dev, CI, prod.

Always commit the lockfile. Use npm ci / pnpm install --frozen-lockfile in CI — they fail if the lockfile doesn’t match package.json (catching forgotten lockfile updates).

When does the lockfile change vs package.json?

Action package.json Lockfile
npm install (no args) unchanged updated if newer matching versions exist
npm install lodash new dep added new entries
npm install lodash@4.17.20 exact version pinned matches
npm update unchanged bumped to latest compatible
npm ci unchanged must match — fails otherwise

In CI: always npm ci (or pnpm/yarn equivalents). In dev: npm install for new deps; lockfile updates land in commit.

Semver in version specifiers — ^, ~, exact?

Spec Matches
1.2.3 exactly 1.2.3
^1.2.3 ≥ 1.2.3, < 2.0.0 (minor + patch, no major)
~1.2.3 ≥ 1.2.3, < 1.3.0 (patch only, no minor)
1.x / 1.* any 1.x.x
* any
>=1.2.3 <2.0.0 explicit range
1.2.3 - 1.5.0 hyphenated range
latest npm dist-tag

Default for npm install foo is ^foo. Bumps minor + patch automatically on npm install, which the lockfile pins exactly.

For strict reproducibility without lockfile reliance: use exact versions everywhere ("lodash": "4.17.21"). Loses auto-patches but removes ambiguity. Most teams accept ^ + lockfile.

Peer dependencies — what are they?

Declared in a package’s peerDependencies. Means “you need to provide this in your project — I don’t bring my own copy.”

json
// react-query/package.json
"peerDependencies": {
  "react": ">=18"
}

The consumer (your app) must have React installed. Without peers, you’d get two React copies (one for your app, one bundled with react-query) → hooks break, contexts split.

pnpm + npm v7+ auto-install peers if missing (with a warning). Yarn Classic doesn’t — must install manually.

Workspaces — same idea across managers?

Yes:

json
// npm / yarn classic — root package.json
{
  "workspaces": ["packages/*", "apps/*"]
}
yaml
# pnpm — pnpm-workspace.yaml
packages:
  - "packages/*"
  - "apps/*"

Internal packages resolve to their workspace path, not npm. pnpm add @my/utils -F @my/web adds an internal dep with workspace:* protocol.

See Monorepo Tools — Turborepo, Nx, pnpm Workspaces for monorepo orchestration on top.

package-lock.json vs pnpm-lock.yaml vs yarn.lock.

Same purpose, different formats. Don’t commit all three — pick one manager, commit its lockfile, others should be in .gitignore. CI uses the same manager.

Lockfile churn / merge conflicts.

Lockfile diffs in PRs can be huge and merge conflicts ugly. Mitigations:

  • save-exact: true in .npmrc so npm install lodash pins exact → fewer auto-bumps over time.
  • Renovate / Dependabot — automate dep updates one at a time in separate PRs; smaller diffs.
  • Lockfile-only modenpm install --package-lock-only updates the lockfile without node_modules, useful for some CI/PR workflows.

When do you delete node_modules and the lockfile?

Almost never. The temptation: “the install is acting weird, let me start clean.” Symptoms:

  • npm install errors: usually due to a mismatched lockfile or a peer dep conflict; fix the cause.
  • node_modules corrupted: rare; deleting it and npm ci rebuilds without touching the lockfile.

Deleting the lockfile to “fix” things is a red flag — you’re erasing the reproducibility contract. If the lockfile is truly bad, regenerate via npm install and review the diff.

Gotchas / edge cases

  • npm install doesn’t always update transitive deps to latest — only the direct dep you’re touching. npm update does. Confusing if you expect transitive auto-bumps.
  • Multiple lockfiles in one repo (e.g., committed yarn.lock and package-lock.json) cause conflicting installs. Pick one.
  • engines field in package.json declares required Node/npm version — enforced by npm install --engine-strict or pnpm by default.
  • .npmrc controls registry, auth tokens, save behavior. Don’t commit auth tokens.
  • Private registries (npm Enterprise, GitHub Packages, JFrog) — config via .npmrc; CI needs the auth.
  • overrides (npm) / resolutions (yarn) / pnpm.overrides — force a transitive dep to a specific version (e.g., for security patches). Use sparingly; understandable in CVE response.
  • Phantom CJS in ESM-only workspace — pnpm strict catches; phantom hoisting masks. Trace dep tree (pnpm why x) to find the culprit.

What a senior is expected to say 6

  • “pnpm for new projects — content-addressable store saves disk, strict mode catches phantom deps, monorepo workspaces work best. npm is fine; Yarn Classic is fading; Yarn Berry’s PnP is niche.”
  • “Always commit the lockfile. CI uses npm ci / pnpm install --frozen-lockfile — installs exactly what’s in the lockfile or fails.”
  • “Semver ^ + lockfile is the standard. Lockfile pins exactness; package.json spec controls what install is allowed to bump.”
  • “Peer deps are a contract — ‘you provide this’ — preventing duplicate-instance bugs (two Reacts, two Vues).”
  • “Deleting the lockfile to ‘fix’ an install is a red flag. The lockfile is the reproducibility contract; investigate the root cause instead.”
  • “Overrides for CVE response — pin a transitive to a patched version when the upstream is slow to update.”

Cross-references

Further reading