devops2025-02-15Β·11Β·201/348

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.0

The project structure includes a monorepo with five packages:

project/
β”œβ”€β”€ apps/
β”‚   β”œβ”€β”€ dashboard/
β”‚   β”œβ”€β”€ admin/
β”‚   └── landing/
β”œβ”€β”€ packages/
β”‚   β”œβ”€β”€ shared/
β”‚   β”œβ”€β”€ ui/
β”‚   └── config/
β”œβ”€β”€ pnpm-workspace.yaml
└── package.json

Problem

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/shared

The 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.6GB

Analysis

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 possible

The 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-builds

Lessons Learned

  1. Run pnpm install in CI with the --frozen-lockfile flag to prevent accidental lockfile modifications during builds.

  2. Use pnpm.overrides in your root package.json to force specific dependency versions across all packages, similar to npm's overrides.

  3. Configure node-linker=hoisted if you encounter issues with packages that assume a flat node_modules structure. Some older packages still expect this layout.

  4. The pnpm audit command works similarly to npm audit but provides cleaner output and can be integrated into CI pipelines easily.

  5. Docker builds benefit significantly from pnpm. Use COPY pnpm-lock.yaml . and COPY 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.