Frontend / Build tools / 07_package_managers.md

Package Managers — npm vs pnpm vs yarn, Lockfile Semantics

Updated 5 interview angles 7 min read source
On this page16
  1. What’s actually different between them?
  2. Why is pnpm faster and disk-efficient?
  3. pnpm “strict” — what does that catch?
  4. What is Yarn Berry’s “Plug’n’Play”?
  5. Lockfile — what’s it for, why commit?
  6. When does the lockfile change vs package.json?
  7. Semver in version specifiers — ^, ~, exact?
  8. Peer dependencies — what are they?
  9. Workspaces — same idea across managers?
  10. package-lock.json vs pnpm-lock.yaml vs yarn.lock.
  11. Lockfile churn / merge conflicts.
  12. When do you delete node_modules and the lockfile?
  13. Gotchas
  14. Related
  15. Further reading
  16. Interview angle

Package Managers — npm vs pnpm vs yarn, Lockfile Semantics

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.

What’s actually different between them?

Manager node_modules Strict imports
npm hoisted flat, full copy per project no
pnpm global content-addressed store + symlinks yes, only declared deps
Yarn Classic hoisted flat, full copy per project no
Yarn Berry none: Plug’n’Play zips in .yarn/cache yes, through PnP

Speed: npm slow, Yarn Classic medium, pnpm and Yarn Berry fast. Lockfiles: package-lock.json, pnpm-lock.yaml, and yarn.lock for both Yarns. All four support workspaces (npm since v7; pnpm’s are the best). Community: npm largest, pnpm growing rapidly, Yarn Classic shrinking, Yarn Berry 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 mode — npm 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

  • 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.

Further reading

Interview angle 5

  • “Which package manager, and why?” - pnpm for new projects. The content-addressable store means one copy of a version on disk regardless of how many projects use it, and its strict linking catches phantom dependencies - code importing a package it never declared, which works until the transitive dep disappears.
  • “What is the lockfile actually for?” - reproducibility. package.json says what range is acceptable; the lockfile records what was actually resolved, including transitives. Commit it, and have CI use npm ci or --frozen-lockfile so it installs exactly that or fails.
  • “Someone deleted the lockfile to fix a broken install.” - that is a red flag, not a fix. It resolves every dependency afresh and hides whatever the real conflict was behind a new set of versions. Investigate the conflict.
  • “What are peer dependencies for?” - declaring “you must provide this”, so the consumer’s copy is used rather than a nested one. It is what prevents two Reacts or two instances of a state library in one tree, which produces hooks-fail and context-not-found bugs that look like anything but a dependency problem.
  • “A CVE lands in a transitive dependency and upstream is slow. What do you do?” - an override or resolution pinning that transitive to the patched version. It is a deliberate, temporary local fix, and it needs removing once upstream catches up or it silently becomes a pin nobody remembers.