npm 12.2.0 vs pnpm 12.9.1 vs Bun 1.4.2 on one 24-dependency React + Vite app: from scratch 14.2 s / 3.1 s / 1.5 s; lockfile + warm cache 2.9 s / 0.28 s / 0.27 s; lockfile + empty cache 3.9 s / 2.3 s / 0.45 s. Disk, lockfile size and the pnpm phantom-dependency catch.
7 min read
On a from-scratch install of the same 24-dependency React + Vite app, npm 12.2.0 took a median 14.2 s, pnpm 12.9.1 took 3.1 s and Bun 1.4.2 took 1.5 s. With a lockfile and a warm cache the gap got wider: 2.9 s for npm ci against 0.28 s for pnpm and 0.27 s for Bun.
Short answer: if install time shows up in your CI bill or your daily loop, pnpm is the safe switch and Bun is the fastest. If you install a few times a day on one small repo, npm with a committed lockfile is fine. No affiliate links in this post.
package.json with 10 dependencies (React 19, React Router 7, TanStack Query 5, Zod 4, Zustand 5 and others) and 14 dev dependencies (Vite 8, Vitest 5, TypeScript 6, ESLint 10, typescript-eslint, Prettier, Tailwind CSS 4, Playwright, jsdom 30). That resolved to 236 packages under npm, 235 under pnpm and 239 under Bun.perf_counter around the whole command: from scratch (no lockfile, no cache), lockfile plus warm cache, lockfile with an empty cache, and a no-op re-install.node_modules came out 19% bigger than npm's (255 MB vs 215 MB). It installed both the glibc and the musl native binaries for Rolldown and Tailwind's oxide engine, where npm and pnpm installed only the glibc one.| Scenario | npm 12.2.0 | pnpm 12.9.1 | Bun 1.4.2 |
|---|---|---|---|
| From scratch: no lockfile, empty cache | 14.2 s (13.6 to 16.0) | 3.1 s (2.8 to 8.0) | 1.5 s (1.4 to 3.4) |
| Lockfile, warm cache, no node_modules | 2.9 s (npm ci) | 0.28 s | 0.27 s |
| Lockfile, empty cache (CI with no cache) | 3.9 s (npm ci) | 2.3 s | 0.45 s |
| Re-install with nothing to do | 0.44 s | 0.04 s | 0.02 s |
From scratch, pnpm was about 4.5x faster than npm and Bun about 9.7x faster. Both pnpm and Bun had one slow outlier each (8.0 s and 3.4 s). I kept those in the ranges because a slow registry response is part of real life, but the medians did not move much.
The most useful line for npm users is the gap between rows 1 and 3. With an empty cache but a committed package-lock.json, npm finished in 3.9 s instead of 14.2 s. Most of npm's from-scratch time went into resolving versions, not downloading. If your CI runs npm install without a lockfile, fixing that is worth more than switching tools.
A caution on network-bound rows: this box pulled a 4.5 MB TypeScript tarball from the npm registry in about 0.04 s. On a home connection rows 1 and 3 will be slower for every tool, and the gap between them may shrink.
| Measure | npm | pnpm | Bun |
|---|---|---|---|
node_modules (apparent size) | 215 MB | 204 MB | 255 MB |
| Cache or store after install | 433 MB | 520 MB (252 MB store + 268 MB metadata cache) | 364 MB |
| Lockfile | package-lock.json 146.7 KB | pnpm-lock.yaml 88.1 KB | bun.lock 68.0 KB |
pnpm's node_modules holds hard links into its store, so a second project with the same packages costs almost nothing extra on disk. That is the main reason teams with many repos or a monorepo pick it. For a single repo the three are close, apart from Bun's extra native binaries.
Everything I checked ran under all three installs: vite --version (8.3.2), tsc -v (6.0.3) and vitest --version (5.0.3). The one difference I hit was pnpm's strict layout. A require('@tailwindcss/oxide') from the app root worked under npm and Bun but failed under pnpm, because that package is a dependency of @tailwindcss/vite, not of my app. pnpm only exposes what you list in package.json.
That strictness is a feature, but it means code that imports packages it never declared (phantom dependencies) will fail after a switch. Fix it by adding the missing packages to package.json, not by turning hoisting back on everywhere.
Other things to check before you move:
package-lock.json when you adopt pnpm or Bun, or CI and teammates will disagree about versions.packageManager field; set it so CI and laptops run the same 12.x binary. The pnpm 12.9.1 post covers the Rust rewrite and its split WASM package.bun install itself.| Situation | My pick |
|---|---|
| Small single repo, a few installs a day | Stay on npm, but commit the lockfile and use npm ci in CI |
| CI runs dozens of installs a day | pnpm: 2.9 s became 0.28 s with a warm cache here |
| Many repos or a monorepo on one machine | pnpm, for the shared store |
| You want the fastest install and already use Bun elsewhere | Bun: 1.5 s from scratch, 0.45 s with an empty cache |
| Code imports packages it does not declare | Fix that first, or pnpm will fail on it |
Bottom line: on my test app pnpm cut a warm-cache install from 2.9 s to 0.28 s and a from-scratch install from 14.2 s to 3.1 s, with the fewest surprises. Bun was faster still, at the cost of a larger node_modules. Who should not switch: a solo developer on one small repo, where the saving is a few seconds a day and the switch costs an afternoon of lockfile and CI changes.
How this was made: I ran every install above on the ShopperCove test box and kept the raw logs; the write-up was drafted with AI help and checked against those logs.
Lab evidence
Hands-on on ShopperCove box 5 Oct 2026 ~22:20-22:30 IST (8 vCPU Intel Xeon / 15 GB shared Linux, US data center, Node 24.21.0). Tools: npm 12.2.0, pnpm 12.9.1 native binary, Bun 1.4.2 (npm latest that evening). Project: package.json with 10 deps (react 19, react-router 7, @tanstack/react-query 5, zod 4, date-fns 4, clsx, lucide-react, zustand 5, axios) + 14 devDeps (vite 8.3.2, @vitejs/plugin-react, typescript 6.0.3, vitest 5.0.3, jsdom 30.1.2, eslint 10.12.0, typescript-eslint 8.71.0, prettier 3.9.9, tailwindcss 4.3.3, @tailwindcss/vite, @playwright/test, @types/*, @testing-library/react). Separate empty caches per tool (npm --cache, pnpm --store-dir + XDG_CACHE_HOME, BUN_INSTALL_CACHE_DIR). Interleaved rounds, perf_counter wall time. From scratch (6 runs): npm 13.58/14.30/16.01/13.95/13.99/14.56 (median 14.15); pnpm 8.04/3.03/2.78/3.12/5.15/3.11 (3.12); bun 1.51/1.41/1.40/3.35/1.52/1.39 (1.46). Lockfile+warm cache (3): npm ci 3.06/2.85/2.89; pnpm frozen 0.29/0.28/0.28; bun frozen 0.28/0.27/0.27. Lockfile+empty cache: npm ci 4.02/3.85/3.68; pnpm 2.29/2.11/2.38; bun 0.45/0.37/0.49. No-op: 0.44/0.04/0.02 medians. Packages: npm added 236, pnpm +235, bun 239. node_modules apparent: 215/204/255 MB (bun installs gnu+musl bindings for @rolldown and @tailwindcss/oxide). Cache after: npm 433 MB, pnpm 520 MB (252 store + 268 metadata), bun 364 MB. Lockfiles 146,722 / 88,143 / 67,963 bytes. vite/tsc/vitest --version OK in all; require(@tailwindcss/oxide) from root fails only under pnpm (strict layout). Registry tarball 4.5 MB in ~0.04 s from box (fast link caveat). No affiliate.
Yarn 4.9.2 (Berry PnP + node-modules linker) vs pnpm 12.9.1 on one 22-dependency React+Vite app. Isolated caches (Yarn enableGlobalCache false; pnpm --store-dir). Warm medians (5): Yarn PnP 1.307 s, Yarn nm 2.471 s, pnpm 0.240 s. Lock+empty medians 1.656 / 2.789 / 0.725 s. Clean disk: Yarn PnP 246 MB; Yarn nm modules 189 MB; pnpm hardlink project 167 MB. Fixture /workspace/bench-yarn-pnpm/. Distinct from pnpm-vs-npm-vs-bun (no Yarn).
Hands-on6 Oct 20264 min read
Hands-on6 Oct 20266 min read
Hands-on6 Oct 20269 min read