frontend / build tools / 07_package_managers.md

Package Managers — npm vs pnpm vs yarn, Lockfile Semantics

7 min read source

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.

Interview Q&A

Q: What’s actually different between them?

A:

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

Q: Why is pnpm faster and disk-efficient?

A: 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.

Q: pnpm “strict” — what does that catch?

A: 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.

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

A: 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.

Q: Lockfile — what’s it for, why commit?

A: 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).

Q: When does the lockfile change vs package.json?

A:

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.

Q: Semver in version specifiers — ^, ~, exact?

A:

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.

Q: Peer dependencies — what are they?

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

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

Q: Workspaces — same idea across managers?

A: Yes:

// npm / yarn classic — root package.json
{
  "workspaces": ["packages/*", "apps/*"]
}
# 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 06_monorepo_tools.md for monorepo orchestration on top.

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

A: 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.

Q: Lockfile churn / merge conflicts.

A: 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.

Q: When do you delete node_modules and the lockfile?

A: 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

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