Tags: web-dev concept

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

npmpnpmyarn
Ships with NodeYesNoNo
LayoutFlat, hoistedContent-addressed store + symlinksFlat, or Plug’n’Play
Disk useHighestLowestHigh
Install speedSlowestFastestFast
Strict about undeclared importsNoYesWith PnP
Lockfilepackage-lock.jsonpnpm-lock.yamlyarn.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-scripts

Worth 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 ci in CI, not npm install — it installs strictly from the lockfile and fails if package.json disagrees
  • Enforce the manager so nobody installs with the wrong one:
{
  "packageManager": "pnpm@9.0.0"
}
  • Pin the Node version in .nvmrc or engines, 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.