npm vs pnpm vs bun
A complete comparison of the three main JavaScript package managers — how they store packages, why speeds differ, phantom dependencies, monorepo support, and exactly when to choose each one.
At a Glance
| Feature | npm | pnpm | bun |
|---|---|---|---|
| Written in | JavaScript | TypeScript | Zig |
| Install speed | Slow | Fast | Fastest |
| Disk usage | High (copies per project) | Low (global store + links) | Medium (global cache) |
| node_modules structure | Flat (hoisted) | Symlinked (strict) | Flat (hoisted) |
| Phantom dependencies | Yes ⚠️ | Blocked by default | Yes (flat mode) |
| Lockfile | package-lock.json |
pnpm-lock.yaml |
bun.lock |
| Workspace/monorepo | Basic | Excellent | Good |
| Runtime | Node.js only | Node.js only | Own runtime (+ Node compat) |
| Test runner | External (Jest/Vitest) | External | Built-in (bun test) |
| Bundler | External (webpack/vite) | External | Built-in (bun build) |
| Ecosystem compatibility | 100% | ~100% | ~95% (some edge cases) |
| Enterprise adoption | Universal | Growing fast | Early adopters |
Speed Comparison
Install speed is measured from cold cache (no packages cached), warm cache (packages already downloaded),
and with existing node_modules (lockfile present, no changes).
Fresh install (cold cache) — Next.js project ~800 packages
With warm cache (packages already in global store)
Storage Model — The Core Difference
This is the most important architectural difference between the three. How packages are stored on disk determines speed, disk usage, and dependency strictness.
npm — Flat copy per project
pnpm — Global content-addressable store + hard links
.pnpm, symlinks from node_modules/pkg → .pnpm/pkg@version.
bun — Global cache + copies (but fast)
npm npm
The original JavaScript package manager. Comes bundled with Node.js — no separate install needed. The default choice and the baseline every other manager is compared against.
Strengths
- Zero setup: Already installed if you have Node.js
- Universal compatibility: Every package, every tool assumes npm
- Mature ecosystem: 10+ years of bug fixes and edge cases handled
- npm scripts: The standard for defining build/test commands
- npm audit: Built-in security vulnerability scanning
Weaknesses
- Slow: Copies packages to every project, no global deduplication
- Disk usage: Massive
node_modulesper project (the famous "heaviest objects in the universe" meme) - Flat/hoisted structure: Allows phantom dependencies (you can import packages you never listed in your own
package.json) - Hoisting conflicts: When two packages need different versions of a dep, npm has to pick one and nest the other — can cause subtle bugs
# .npmrc
registry=https://registry.npmjs.org/
save-exact=true # pin exact versions (no ^ or ~)
fund=false # suppress funding messages
audit=false # suppress audit on install
legacy-peer-deps=true # old behavior for peer deps (avoid if possible)
pnpm pnpm
"Performant npm." Keeps the npm registry and package.json format but replaces
the installation algorithm entirely with a content-addressable global store and symlinks.
The preferred choice for monorepos and teams that care about correctness.
How the symlinked node_modules works
Strengths
- Disk efficient: Global store — install 100 projects, TypeScript only lives on disk once
- Fast warm installs: Hard links are O(1) — no actual copying
- Strict by default: Only packages in your
package.jsonare accessible — phantoms blocked - Best monorepo support:
pnpm-workspace.yaml, workspace protocols,--filter - Deterministic: Same package tree every time across machines
Weaknesses
- Symlinks cause issues: Some tools don't follow symlinks correctly (rare but it happens)
- Strictness can break things: Packages that assume phantom deps will fail until patched
- Learning curve: The
.pnpmfolder structure confuses newcomers - Windows symlinks: Historically required developer mode on Windows (now improved)
# .npmrc (pnpm reads this too)
shamefully-hoist=true # ⚠️ fall back to npm-style flat (defeats the point)
strict-peer-dependencies=false # don't error on peer dep mismatches
auto-install-peers=true # auto-install missing peer deps
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"
bun bun
Bun is an all-in-one JavaScript toolkit: runtime, package manager, bundler, and test runner. Written in Zig for maximum performance. It's not just a package manager — it's an alternative to the entire Node.js + npm + webpack/vite + Jest stack.
bun is more than a package manager
# Run a TypeScript file directly
bun run index.ts
# No tsc needed — built-in TS support
# JSX supported without config
# Web APIs built-in (fetch, WebSocket)
# Jest-compatible API
bun test
# test files: *.test.ts, *.spec.ts
import { test, expect } from "bun:test";
test("adds numbers", () => {
expect(1 + 1).toBe(2);
});
# Bundle for browser
bun build ./src/index.ts \
--outdir ./dist \
--target browser
# Target: browser | bun | node
# Supports tree-shaking, minification
Strengths
- Fastest installs: Native Zig code, parallel downloads, optimized HTTP
- All-in-one: Replace Node.js + npm + Jest + esbuild with one tool
- TypeScript natively: Run
.tsfiles directly — nots-nodeor build step for scripts - Drop-in for npm:
bun installreads yourpackage.jsonandpackage-lock.json - Web APIs:
fetch,WebSocket,Request,Responsebuilt in (same as browser) - Hot reloading:
bun --hotfor development servers
Weaknesses
- Not 100% Node.js compatible: Some Node.js APIs behave differently; some native addons don't work
- Younger ecosystem: Less battle-tested, more frequent breaking changes
- Flat node_modules: Same phantom dependency problem as npm
- Linux-first: Historically best on macOS/Linux; Windows support improved but still behind
- Binary lockfile (old):
bun.lockbwas binary — not human-readable. Nowbun.lock(text) in v1.1+
bun install) while still running your app with Node.js. Many teams do this — get bun's speed for installs in CI, keep Node.js for production.
Command Cheatsheet
| Task | npm | pnpm | bun |
|---|---|---|---|
| Install all deps | npm install | pnpm install | bun install |
| Add a package | npm install lodash | pnpm add lodash | bun add lodash |
| Add dev dependency | npm install -D vitest | pnpm add -D vitest | bun add -d vitest |
| Remove a package | npm uninstall lodash | pnpm remove lodash | bun remove lodash |
| Update a package | npm update lodash | pnpm update lodash | bun update lodash |
| Update all packages | npm update | pnpm update --latest | bun update |
| Run a script | npm run build | pnpm run build | bun run build |
| Run script (shorthand) | npm run dev | pnpm dev | bun dev |
| Execute a package | npx tsc | pnpm dlx tsc / pnpm exec tsc | bunx tsc |
| List installed pkgs | npm list | pnpm list | bun pm ls |
| Security audit | npm audit | pnpm audit | bun audit (v1.2+) |
| Check outdated | npm outdated | pnpm outdated | — |
| Publish a package | npm publish | pnpm publish | bun publish |
| Create project | npm create vite@latest | pnpm create vite@latest | bun create vite |
| Install globally | npm install -g typescript | pnpm add -g typescript | bun add -g typescript |
| Monorepo filter | npm run build -w packages/ui | pnpm --filter ui build | bun run --filter ui build |
run), pnpm lets you skip it for scripts: pnpm dev = pnpm run dev. Bun does the same.
Lockfiles
Lockfiles pin every package to an exact version so installs are reproducible. Never delete them and always commit them to git. One lockfile per project — don't mix.
{
"lockfileVersion": 3,
"packages": {
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://...",
"integrity": "sha512-..."
}
}
}
lockfileVersion: '9.0'
importers:
.:
dependencies:
lodash:
specifier: ^4.17.21
version: 4.17.21
packages:
lodash@4.17.21:
resolution: {integrity: sha512-...}
{
"lockfileVersion": 1,
"packages": {
"lodash": [
"lodash@4.17.21",
"https://...",
{},
"sha512-..."
]
}
}
package-lock.json AND pnpm-lock.yaml, your team will get different package trees depending on which tool they use. Add the ones you don't use to .gitignore.
# If using pnpm — ignore the others
package-lock.json
yarn.lock
bun.lock
# If using bun — ignore the others
package-lock.json
yarn.lock
pnpm-lock.yaml
Workspaces / Monorepo
Workspaces let you manage multiple packages in one repo — a monorepo. All three support workspaces, but pnpm has the best ergonomics and strictness.
// package.json (root)
{
"workspaces": [
"apps/*",
"packages/*"
]
}
# Run in specific workspace
npm run build -w apps/web
npm install lodash -w packages/ui
# pnpm-workspace.yaml (root)
packages:
- "apps/*"
- "packages/*"
# Filter commands
pnpm --filter web build
pnpm --filter "...^web" build
# ^ = run in web + all deps of web
# Workspace protocol in package.json
"@myorg/ui": "workspace:*"
// package.json (root)
{
"workspaces": [
"apps/*",
"packages/*"
]
}
# Filter commands
bun run --filter web build
bun add lodash --filter packages/ui
# workspace: protocol supported
"@myorg/ui": "workspace:*"
pnpm workspace: protocol
The workspace: prefix tells pnpm this is an internal package — not from the registry. This prevents accidentally publishing with a wrong version and enables strict local linking.
// apps/web/package.json
{
"dependencies": {
"@myorg/ui": "workspace:*", // always link local, any version
"@myorg/utils": "workspace:^1.0", // link local, semver range
"@myorg/config": "workspace:~" // link local, patch range
}
}
// When publishing, pnpm replaces workspace: with the real version
// workspace:* → "1.2.3" (current version of the package)
Config Files
| Config file | Used by | Purpose |
|---|---|---|
package.json | All | Dependencies, scripts, metadata |
package-lock.json | npm | Lockfile |
pnpm-lock.yaml | pnpm | Lockfile |
bun.lock | bun | Lockfile (text, v1.1+) |
.npmrc | npm + pnpm + bun | Registry, auth, behavior config |
pnpm-workspace.yaml | pnpm | Monorepo workspace definitions |
bunfig.toml | bun | Bun-specific config (install, test, build) |
[install]
registry = "https://registry.npmjs.org"
optional = false # skip optional deps
peer = true # install peer deps
production = false # include dev deps
frozenLockfile = false # don't write lockfile (for CI set true)
[install.scopes]
# Scoped registry (like npm org)
"@myorg" = { url = "https://npm.myorg.com", token = "..." }
[test]
timeout = 5000
# Jest-compatible options
Phantom Dependencies
A phantom dependency is a package you import in your code that isn't listed in your package.json
— you're relying on it because something else installed it. It works today, but breaks when the other package stops depending on it.
// Your package.json only has:
{ "dependencies": { "react": "^18" } }
// But react internally depends on loose-envify
// npm hoists it to node_modules/loose-envify
// So this accidentally works:
import looseEnvify from 'loose-envify'; // phantom dep!
// Works until React stops depending on loose-envify
// Then your CI explodes with "Cannot find module 'loose-envify'"
How each PM handles this
// Flat node_modules = all transitive
// deps hoisted to top level
// You CAN import them — no error
// But it's fragile and wrong
// Symlinked structure means only
// YOUR declared deps are accessible
// Transitive deps are nested inside
// .pnpm/ and unreachable from your code
// Error: Cannot find module 'loose-envify'
// Fix: add it to your package.json!
// Flat hoisted structure like npm
// Phantoms work — same fragility
// Use --strict flag or pnpm
// if you need strictness
shamefully-hoist=true to .npmrc to fall back to flat mode — but this defeats pnpm's strictness.
Migration Guide
npm → pnpm
# 1. Install pnpm
npm install -g pnpm
# or (recommended)
corepack enable && corepack prepare pnpm@latest --activate
# 2. In your project — import existing lockfile
pnpm import # converts package-lock.json → pnpm-lock.yaml
# 3. Delete old artifacts
rm -rf node_modules package-lock.json
# 4. Install
pnpm install
# 5. Add to .gitignore
echo "package-lock.json" >> .gitignore
echo "yarn.lock" >> .gitignore
# 6. Tell team to use pnpm (add to package.json)
# "packageManager": "pnpm@9.x.x"
npm / pnpm → bun
# 1. Install bun
curl -fsSL https://bun.sh/install | bash
# 2. bun reads package.json as-is — just install
bun install
# 3. bun.lock is generated automatically
# 4. Delete old lockfiles you no longer use
rm package-lock.json pnpm-lock.yaml
# 5. Update scripts (optional — run is bun-compatible)
# Most npm scripts work as-is: bun run dev
# 6. Add to package.json
# "packageManager": "bun@1.x.x"
Enforce which PM to use (packageManager field)
{
"packageManager": "pnpm@9.1.0"
}
// Corepack (bundled with Node 16+) enforces this:
// If a dev runs "npm install", corepack intercepts and errors:
// "This project is configured to use pnpm"
# Enable corepack on each developer machine:
corepack enable
When to Use Which
| Scenario | Recommended | Why |
|---|---|---|
| New Next.js / React app | pnpm | Fast, strict, great Next.js support |
| Monorepo (Turborepo, Nx) | pnpm | Best workspace protocol, --filter, disk efficiency |
| Node.js server (Express, Fastify) | pnpm or npm | Either works; pnpm faster. npm if you want zero setup |
| CLI tool or npm library | pnpm | Strictness catches phantom dep bugs before publish |
| Personal project / fast prototyping | bun | Fastest setup, TypeScript native, no config needed |
| Writing scripts (replacing ts-node) | bun | bun script.ts just works — no tsc, no ts-node |
| Bun runtime app | bun | Obvious — use native tooling |
| Legacy project, minimal changes | npm | Zero migration, universal tooling support |
| CI/CD — fast install step | bun or pnpm | bun fastest cold, pnpm fastest with cache. Both >> npm |
| Docker builds (small layers) | pnpm | pnpm fetch + pnpm install --offline for perfect layer caching |
| Team unfamiliar with new tools | npm | No onboarding needed, everyone knows it |
The short answer
- New project with a team → pnpm — strictness, speed, monorepo, correctness
- Personal scripts / fast prototypes → bun — zero config, just works, TypeScript native
- Already on npm, don't want to migrate → npm — it's fine, just slower
# Install pnpm (recommended: via corepack)
corepack enable
corepack prepare pnpm@latest --activate
# Install bun
curl -fsSL https://bun.sh/install | bash
# Windows: powershell -c "irm bun.sh/install.ps1 | iex"
# npm — already installed with Node.js
node -v && npm -v