Package Managers
Date: 2026-08-17
Tools that resolve, download and lay out your dependencies. The differences between them are almost entirely about disk layout and resolution strictness — and the layout decides whether your code can import packages you never declared.
A package manager reads your declared dependencies, resolves the full tree including transitive dependencies, downloads them, and arranges them on disk so the runtime can find them.
The three in use
| npm | pnpm | yarn | |
|---|---|---|---|
| Ships with Node | Yes | No | No |
| Layout | Flat, hoisted | Content-addressed store + symlinks | Flat, or Plug’n’Play |
| Disk use | Highest | Lowest | High |
| Install speed | Slowest | Fastest | Fast |
| Strict about undeclared imports | No | Yes | With PnP |
| Lockfile | package-lock.json | pnpm-lock.yaml | yarn.lock |
[CHECK: current version numbers and feature parity before relying on any specific comparison — these tools change quickly.]
Hoisting, and the bug it creates
npm and yarn flatten the dependency tree so most packages sit directly in node_modules, which avoids deep nesting and duplicate copies.
node_modules/
├── express/
├── lodash/ ← a dependency OF express,
└── your-app-dep/ hoisted to the top
The consequence: you can import lodash without declaring it, and it works — until the day express drops it, or a fresh install resolves differently.
// works locally, not declared in package.json
import { debounce } from 'lodash'This is a real and common source of “works on my machine”. The package was there by accident.
pnpm prevents it structurally by creating a node_modules containing only your declared dependencies, with everything else symlinked into a content-addressed store. Undeclared imports fail immediately.
The store
pnpm’s disk model is the other substantive difference. Packages are stored once globally and hard-linked into each project:
10 projects using react 18.2.0
npm 10 copies on disk
pnpm 1 copy, 10 links
On a machine with many projects this is a large saving, and it makes installs faster because most packages are already present.
What resolution actually does
package.json says ^4.17.0
registry has 4.17.0 … 4.21.4, 5.0.1
resolver picks 4.21.4
← highest matching the range
← unless the lockfile pins otherwise
Then it does the same for every transitive dependency, resolving conflicts where two packages want incompatible versions — by nesting a second copy, or by failing.
The lockfile records the resolved result, so the next install reproduces it exactly rather than re-resolving — Lockfiles.
Scripts, and the lifecycle risk
{
"scripts": {
"build": "vite build",
"postinstall": "node setup.js"
}
}postinstall and friends run arbitrary code on install, including from transitive dependencies you’ve never heard of. This is the primary supply-chain attack path.
npm install --ignore-scriptsWorth using in CI, with an explicit allow-list for the packages that genuinely need scripts — Supply Chain Risk.
Practical guidance
- Pick one and commit to it. Mixed lockfiles in one repository produce divergent trees and confusing bugs
- Use
npm ciin CI, notnpm install— it installs strictly from the lockfile and fails ifpackage.jsondisagrees - Enforce the manager so nobody installs with the wrong one:
{
"packageManager": "pnpm@9.0.0"
}- Pin the Node version in
.nvmrcorengines, since resolution can differ across Node versions - pnpm’s strictness is a feature, and it will surface undeclared dependencies the day you switch. That’s a backlog you had already, now visible
The realistic recommendation
pnpm for new projects — the strictness catches a real class of bug and the disk saving is substantial. npm if you value zero setup, since it ships with Node and everyone knows it.
The choice matters far less than the discipline around it: commit the lockfile, install from it in CI, and keep dependencies few — Dependency Management.