pnpm Package Manager: Speed and Disk Efficiency in Practice
How pnpm's content-addressable storage and strict dependency resolution improved our monorepo performance and eliminated phantom dependency issues.
Introduction
When our team's node_modules folders started consuming over 8GB of disk space across three projects, and phantom dependency bugs kept surfacing in production, we decided to migrate from npm to pnpm. The results were striking: install times dropped by 60%, disk usage fell by 70%, and the strict dependency resolution caught several hidden bugs that npm's flat node_modules structure had been masking for months.
This post documents the migration process, the technical reasons pnpm performs better, and the issues we encountered along the way.
Environment
# System info
uname -a
# Linux dev-server 6.5.0 #1 SMP x86_64 GNU/Linux
node --version
# v20.11.0
npm --version
# 10.2.4
# Install pnpm
corepack enable
corepack prepare pnpm@8.15.0 --activate
pnpm --version
# 8.15.0The project structure includes a monorepo with five packages:
project/
βββ apps/
β βββ dashboard/
β βββ admin/
β βββ landing/
βββ packages/
β βββ shared/
β βββ ui/
β βββ config/
βββ pnpm-workspace.yaml
βββ package.jsonProblem
The npm-based monorepo had several recurring issues that prompted the migration. First, fresh installs took over 5 minutes even on a fast connection. Second, different developers occasionally got different dependency versions despite having the same package-lock.json, leading to "works on my machine" bugs. Third, some packages were importing modules that were not listed in their own package.json, relying on hoisted packages from other workspaces.
Here is an example of the phantom dependency issue:
// packages/shared/src/helper.ts
// This import WORKS with npm because lodash is hoisted from another package
import { debounce } from 'lodash';
// But when deployed independently, this import FAILS
// because lodash is not a dependency of @myorg/sharedThe disk usage problem was severe:
# Before migration (npm)
du -sh node_modules/
# 3.2GB (root)
du -sh apps/*/node_modules/
# 1.1GB (dashboard)
# 890MB (admin)
# 450MB (landing)
# Total: ~5.6GBAnalysis
npm uses a flat node_modules structure where all dependencies are hoisted to the root. This saves disk space but creates implicit dependencies that packages can accidentally rely on. When package A depends on lodash and package B also depends on lodash, npm hoists lodash to the root, and package B can import it without declaring the dependency.
pnpm solves this with a content-addressable store and symlinks. Every package gets only its declared dependencies through symlinks to the global store. This means phantom dependencies cause immediate build failures, which is the correct behavior.
# pnpm's store structure
ls -la packages/shared/node_modules/
# lodash -> ../../../node_modules/.pnpm/lodash@4.17.21/node_modules/lodash
# The symlink points to the global content-addressable store
# No phantom dependencies possibleThe install speed improvement comes from pnpm's parallel downloading and hard linking strategy. Instead of copying files, pnpm creates hard links from the global store, which is much faster than npm's copy-based approach.
Solution
Migrate incrementally. Start by creating the workspace configuration and then convert each package.
Step 1: Create pnpm-workspace.yaml:
packages:
- "apps/*"
- "packages/*"Step 2: Remove existing lockfiles and node_modules:
rm -rf node_modules package-lock.json
find . -name "node_modules" -type d -maxdepth 3 -exec rm -rf {} +
find . -name "package-lock.json" -type f -maxdepth 2 -exec rm {} +Step 3: Update root package.json to use pnpm scripts:
{
"name": "myproject",
"private": true,
"scripts": {
"dev": "pnpm -r --parallel run dev",
"build": "pnpm -r run build",
"lint": "pnpm -r run lint",
"test": "pnpm -r run test",
"clean": "pnpm -r exec rm -rf node_modules dist .next"
},
"engines": {
"node": ">=20.0.0",
"pnpm": ">=8.0.0"
},
"packageManager": "pnpm@8.15.0"
}Step 4: Add .npmrc configuration:
# Strict mode prevents phantom dependencies
strict-peer-dependencies=true
# Auto-install hoisted dependencies
shamefully-hoist=false
# Use hard links for faster installs
node-linker=hoisted
# Cache settings
cache-dir=.pnpm-store
# Registry configuration
registry=https://registry.npmjs.org/Step 5: Fix phantom dependency imports that pnpm catches:
// Before: relied on hoisted dependency
import { v4 as uuidv4 } from 'uuid';
// After: add the dependency explicitly
// Run: pnpm add uuid -F @myorg/shared
import { v4 as uuidv4 } from 'uuid';Step 6: Install dependencies and verify:
# Clean install
pnpm install
# Output shows the savings
# Progress: resolved 1,847, reused 1,432 (77%), downloaded 415
# Packages: +1,847
# Progress: 2.1s
# Check the store size
du -sh node_modules/.pnpm/
# 1.8GB (vs 3.2GB with npm)Step 7: Verify the strict dependency resolution:
# This command shows which packages have unmet dependencies
pnpm ls --depth=0
# Check for missing peer dependencies
pnpm approve-buildsLessons Learned
Run
pnpm installin CI with the--frozen-lockfileflag to prevent accidental lockfile modifications during builds.Use
pnpm.overridesin your rootpackage.jsonto force specific dependency versions across all packages, similar to npm'soverrides.Configure
node-linker=hoistedif you encounter issues with packages that assume a flatnode_modulesstructure. Some older packages still expect this layout.The
pnpm auditcommand works similarly tonpm auditbut provides cleaner output and can be integrated into CI pipelines easily.Docker builds benefit significantly from pnpm. Use
COPY pnpm-lock.yaml .andCOPY pnpm-workspace.yaml .to leverage Docker layer caching for faster container builds.
This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.