C.W.K.
Stream
Lesson 03 of 05 · published

npm vs pnpm vs Bun — How Package Managers Actually Work

~14 min · modules, npm, pnpm, bun, lockfiles

Level 0Node Curious
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
"All three resolve the same registry. The disagreement is about where the bytes go on disk."

npm — The Flat Tree

npm 3+ hoists dependencies into a single flat node_modules/ directory. If package A and package B both depend on lodash@4.17, you get one copy of lodash at the top level. If A wants lodash@4.17 but B wants lodash@3.10, npm hoists one of them to the top and nests the other deeper. The advantage: small footprint, simple resolution algorithm Node already understood (walk up the directory tree).

The disadvantage: phantom dependencies. Your code can require('underscore') even if your package.json doesn't list underscore — because some transitive dep brought it in and hoisted it. Works on your machine, breaks the moment that transitive dep updates and stops pulling underscore. Real bugs, hard to catch.

pnpm — Content-Addressable Store + Symlinks

pnpm keeps a single content-addressable store at ~/.local/share/pnpm/store (or similar). Every version of every package on your machine, ever, is stored once. node_modules in your project is just symlinks (or hardlinks) into that store. Disk usage drops by an order of magnitude for anyone working on multiple Node projects.

Critically, pnpm by default uses strict isolation: your node_modules/ only contains the packages your package.json declares. Transitive deps live in node_modules/.pnpm/ in a flat layout that Node's resolver can find but your code can't directly import. Result: phantom dependencies become impossible. If you didn't list it, you can't require it.

This is why most monorepo tooling (Turborepo, Nx, modern Next.js) recommends pnpm. The strictness is a feature.

Bun — JS Runtime With Bundled Package Manager

Bun is a full alternative JavaScript runtime, but its bun install is a Node-compatible package manager. It writes a node_modules tree that Node can use. The selling point is speed — Bun's installer is written in Zig and is often 5-20x faster than npm.

You can use bun install with node script.js (Bun as installer, Node as runtime). You can also run scripts with bun script.js (Bun as both). The dependency tree Bun writes is npm-compatible, so this works.

Lockfiles — The Ground Truth

Every package manager writes a lockfile that pins the exact version of every dependency (including transitive):
  • npm → package-lock.json
  • pnpm → pnpm-lock.yaml
  • yarn → yarn.lock
  • bun → bun.lockb (binary)
Always commit your lockfile. It's how your CI gets the same versions as your laptop, and how your laptop gets the same versions as your teammate's. npm install respects ranges and updates the lockfile; npm ci respects the lockfile and errors if it mismatches package.json. Use ci in CI.

Switching Managers — Don't Mix

Within a single project, pick one and stick with it. Mixing npm install and pnpm install in the same repo creates conflicting lockfiles, mismatched node_modules layouts, and unreproducible installs. Use packageManager in package.json plus Corepack to enforce: "packageManager": "pnpm@9.10.0" tells everyone (and CI) which manager and version is canonical.

Pippa's Confession

For my first year I thought "npm vs pnpm" was a taste war, like vim vs emacs. Dad showed me the cwk-site repo's du -sh node_modules with npm (2GB) vs pnpm (180MB). The size difference is one measurement; the no-phantom-deps strictness is another. Now when I start a new project I default to pnpm without thinking. The exception is when a tool refuses pnpm (rare in 2026) — then I match the tool.

Code

Inspecting and switching between package managers·bash
# Inspect what a manager actually wrote
ls node_modules/       # what's at the top level?
ls node_modules/.pnpm/  # pnpm's flat store mirror (only for pnpm)

# Find a transitive dependency in npm-style hoisting
find node_modules -name 'underscore' -type d -maxdepth 4
# If found at top level without you depending on it = phantom dep.

# pnpm: detect what's actually allowed to import
pnpm why underscore
# Tells you which direct dep, if any, pulls it in.

# Switch between managers cleanly
rm -rf node_modules package-lock.json pnpm-lock.yaml yarn.lock bun.lockb
pnpm install   # or npm install, or yarn install, or bun install
Pinning the manager via Corepack·json
// package.json — pin the package manager for the whole team
{
  "name": "my-app",
  "packageManager": "pnpm@9.10.0",
  "engines": {
    "node": ">=22",
    "pnpm": ">=9"
  }
}

// Now `corepack enable` makes pnpm@9.10.0 the auto-installed manager
// for anyone who runs ANY package manager command in this repo.
// CI gets reproducibility for free.

External links

Exercise

Pick a small Node project. Time npm install from a clean state (delete node_modules first), record the seconds and the resulting size: du -sh node_modules. Now clean again and try pnpm install. Then bun install. Tabulate: install time, disk size, number of files (find node_modules -type f | wc -l). The differences are real, and the winner depends on what you optimize for.
Hint
If pnpm complains about packageManager field, run corepack enable first (Corepack ships with Node 16+). The du -sh size for pnpm will look misleadingly small because the bulk lives in ~/.local/share/pnpm/store — that's the point.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue
💛 by Ttoriwarm

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.