← Study Notes
📦 Frontend · Package Managers

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.

npm v10 pnpm v9 bun v1.1+ Workspaces Lockfiles Storage model
Section 01

At a Glance

npm
v10 · Node.js default
Bundled with Node Flat node_modules package-lock.json
pnpm
v9 · Performant npm
Content-addressable store Strict (no phantom deps) pnpm-lock.yaml
bun
v1.1 · All-in-one runtime
Runtime + PM + Bundler Fastest installs bun.lock (text)
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
Section 02

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

npm
~45s (baseline)
pnpm
~19s (2.4× faster)
bun
~8s (5.6× faster)

With warm cache (packages already in global store)

npm
~20s
pnpm
~6s (hard links)
bun
~2s (native Zig)
ℹ️
Why bun is fastest: Written in Zig (native compiled), uses its own HTTP client and archive parser, and parallelizes every download+extract step. npm and pnpm are JavaScript programs running on Node.js — there's inherent overhead in just starting up.
Why pnpm is fast despite being JS: After the first install, pnpm uses hard links from a global content-addressable store to place packages. A hard link is essentially instant (no file copy). npm copies files every time.
Section 03

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

// Every project gets its own FULL copy of every package
project-A/node_modules/
lodash/ ← full copy (500KB)
react/ ← full copy (300KB)
typescript/ ← full copy (12MB)
project-B/node_modules/
lodash/ ← ANOTHER full copy (500KB)
react/ ← ANOTHER full copy (300KB)
typescript/ ← ANOTHER full copy (12MB)
// 10 projects × 12MB TypeScript = 120MB on disk just for TS

pnpm — Global content-addressable store + hard links

// Global store: ONE copy of each package version, ever
~/.pnpm-store/v3/files/
00/ab12cd... ← actual file (content-addressed hash)
ff/891234... ← actual file
// Each project's node_modules = hard links INTO the global store
project-A/node_modules/.pnpm/
lodash@4.17.21/node_modules/lodash/ ← hard links (instant, no copy)
project-A/node_modules/
lodash → symlink to .pnpm/lodash@4.17.21/...
// 10 projects share the same TypeScript files — only 12MB total
💡
Hard link vs symlink: A hard link is another directory entry pointing to the same inode — the OS sees it as the same file occupying the same disk blocks. A symlink is a pointer to another path. pnpm uses both: hard links from store → .pnpm, symlinks from node_modules/pkg.pnpm/pkg@version.

bun — Global cache + copies (but fast)

// Global cache stores downloaded tarballs
~/.bun/install/cache/
lodash@4.17.21@@@1/ ← extracted package
// node_modules = hardlinks or copies from cache (platform-dependent)
project-A/node_modules/
lodash/ ← hardlinks on macOS/Linux
// Flat structure (like npm) — all packages hoisted to top level
Section 04

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_modules per 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
npm .npmrc config
# .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)
Section 05

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

node_modules/
.pnpm/ ← virtual store (internal)
react@18.2.0/node_modules/
react/ ← hard linked from global store
react → .pnpm/react@18.2.0/node_modules/react
lodash → .pnpm/lodash@4.17.21/node_modules/lodash
// react's OWN dependencies live inside react@18.2.0/node_modules/ // — isolated, can't bleed into your code

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.json are 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 .pnpm folder structure confuses newcomers
  • Windows symlinks: Historically required developer mode on Windows (now improved)
pnpm .npmrc / pnpm-specific config
# .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/*"
Section 06

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

Runtime
# 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)
Test runner
# 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);
});
Bundler
# 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 .ts files directly — no ts-node or build step for scripts
  • Drop-in for npm: bun install reads your package.json and package-lock.json
  • Web APIs: fetch, WebSocket, Request, Response built in (same as browser)
  • Hot reloading: bun --hot for 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.lockb was binary — not human-readable. Now bun.lock (text) in v1.1+
🟡
bun as package manager only: You can use bun just for installs (via 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.
Section 07

Command Cheatsheet

Task npm pnpm bun
Install all depsnpm installpnpm installbun install
Add a packagenpm install lodashpnpm add lodashbun add lodash
Add dev dependencynpm install -D vitestpnpm add -D vitestbun add -d vitest
Remove a packagenpm uninstall lodashpnpm remove lodashbun remove lodash
Update a packagenpm update lodashpnpm update lodashbun update lodash
Update all packagesnpm updatepnpm update --latestbun update
Run a scriptnpm run buildpnpm run buildbun run build
Run script (shorthand)npm run devpnpm devbun dev
Execute a packagenpx tscpnpm dlx tsc / pnpm exec tscbunx tsc
List installed pkgsnpm listpnpm listbun pm ls
Security auditnpm auditpnpm auditbun audit (v1.2+)
Check outdatednpm outdatedpnpm outdated
Publish a packagenpm publishpnpm publishbun publish
Create projectnpm create vite@latestpnpm create vite@latestbun create vite
Install globallynpm install -g typescriptpnpm add -g typescriptbun add -g typescript
Monorepo filternpm run build -w packages/uipnpm --filter ui buildbun run --filter ui build
💡
pnpm shorthand: Unlike npm (which always needs run), pnpm lets you skip it for scripts: pnpm dev = pnpm run dev. Bun does the same.
Section 08

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.

package-lock.json
{
  "lockfileVersion": 3,
  "packages": {
    "node_modules/lodash": {
      "version": "4.17.21",
      "resolved": "https://...",
      "integrity": "sha512-..."
    }
  }
}
pnpm-lock.yaml
lockfileVersion: '9.0'

importers:
  .:
    dependencies:
      lodash:
        specifier: ^4.17.21
        version: 4.17.21

packages:
  lodash@4.17.21:
    resolution: {integrity: sha512-...}
bun.lock (v1.1+)
{
  "lockfileVersion": 1,
  "packages": {
    "lodash": [
      "lodash@4.17.21",
      "https://...",
      {},
      "sha512-..."
    ]
  }
}
🚨
Never commit multiple lockfiles. If you have 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.
.gitignore Keep only the lockfile you use
# 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
Section 09

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.

npm workspaces
// package.json (root)
{
  "workspaces": [
    "apps/*",
    "packages/*"
  ]
}

# Run in specific workspace
npm run build -w apps/web
npm install lodash -w packages/ui
pnpm workspaces
# 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:*"
bun workspaces
// 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.

pnpm Referencing internal packages
// 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)
Section 10

Config Files

Config fileUsed byPurpose
package.jsonAllDependencies, scripts, metadata
package-lock.jsonnpmLockfile
pnpm-lock.yamlpnpmLockfile
bun.lockbunLockfile (text, v1.1+)
.npmrcnpm + pnpm + bunRegistry, auth, behavior config
pnpm-workspace.yamlpnpmMonorepo workspace definitions
bunfig.tomlbunBun-specific config (install, test, build)
bun bunfig.toml
[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
Section 11

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.

Example phantom dependency scenario
// 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

npm — allows phantoms
// Flat node_modules = all transitive
// deps hoisted to top level
// You CAN import them — no error
// But it's fragile and wrong
pnpm — blocks phantoms
// 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!
bun — allows phantoms
// Flat hoisted structure like npm
// Phantoms work — same fragility
// Use --strict flag or pnpm
// if you need strictness
💡
pnpm escape hatch: If a package in your project expects phantom deps (e.g., some tooling was written assuming flat node_modules), add shamefully-hoist=true to .npmrc to fall back to flat mode — but this defeats pnpm's strictness.
Section 12

Migration Guide

npm → pnpm

Migration npm to 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

Migration npm/pnpm to 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)

package.json Lock your team to one PM
{
  "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
Section 13

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

🎯
Default recommendation in 2026:
  • New project with a teampnpm — strictness, speed, monorepo, correctness
  • Personal scripts / fast prototypesbun — zero config, just works, TypeScript native
  • Already on npm, don't want to migratenpm — it's fine, just slower
Quick install reference
# 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