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:
- Checks if
react@18.2.0is already in the global store (~/.pnpm-store). - If yes: creates hardlinks/symlinks from
node_modulesto the store. - 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.cjsmanifest 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 lodashresolves to whatever version matches^4.17.20today.- 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(orpnpm 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: truein.npmrcsonpm install lodashpins 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-onlyupdates the lockfile withoutnode_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 installerrors: usually due to a mismatched lockfile or a peer dep conflict; fix the cause.node_modulescorrupted: rare; deleting it andnpm cirebuilds 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 installdoesn’t always update transitive deps to latest — only the direct dep you’re touching.npm updatedoes. Confusing if you expect transitive auto-bumps.- Multiple lockfiles in one repo (e.g., committed
yarn.lockandpackage-lock.json) cause conflicting installs. Pick one. enginesfield inpackage.jsondeclares required Node/npm version — enforced bynpm install --engine-strictor pnpm by default..npmrccontrols 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 whatinstallis 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
- Module formats (where install layouts matter): 03_module_formats.md
- Monorepo tools (built on workspaces): 06_monorepo_tools.md
- Project structure: ../12_project_structure/
Further reading
- pnpm docs: https://pnpm.io/
- npm docs — package-lock.json: https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json
- Semver: https://semver.org/
- Renovate: https://docs.renovatebot.com/