Why You Should Switch to pnpm

If you are still using npm or Yarn Classic, you are wasting gigabytes of local storage, slowing down your CI/CD pipelines, and opening the door to silent dependency bugs.

pnpm (performant npm) solves these issues out of the box. By replacing file duplication with a single, content-addressable store and using hard links to assemble node_modules, pnpm makes dependency resolution fast, strict, and disk-efficient.

The Core Problem: The node_modules Black Hole

Traditional package managers have a fundamental flaw: they duplicate files. If you have ten projects using React, npm will download and save React ten times on your hard drive. This wastes disk space and slows down installation times since every dependency must be copied file-by-file into each project’s directory.

Additionally, to resolve nested dependencies without deep directory trees, npm flattens node_modules. This flattening introduces phantom dependencies—where your application can successfully import a package that is not declared in your package.json, simply because another package you installed happens to depend on it.

How pnpm Works: Content-Addressable Storage

Instead of duplicating files or flattening directories, pnpm keeps a single, global content-addressable store at ~/.local/share/pnpm/store.

When you run pnpm install, pnpm checks the store first. If a package has already been downloaded, pnpm creates a hard link from the global store to your project’s node_modules.

graph LR %% Global Store Styling classDef store fill:#e1f5fe,stroke:#0288d1,stroke-width:2px,stroke-dasharray: 5 5; %% Project Styling classDef project fill:#f9fbe7,stroke:#689f38,stroke-width:2px; %% Link Styling classDef linkNode fill:#fff3e0,stroke:#f57c00,stroke-width:1px; subgraph GlobalStore [Global Content-Addressable Store] pkgA["lodash@4.17.21 (.js/.json files)"] pkgB["react@18.2.0 (.js/.json files)"] end class pkgA,pkgB store; subgraph ProjectA [Project A .pnpm virtual store] pA_sym["node_modules/lodash"] -- Symlink --> pA_hard["nested lodash@4.17.21/node_modules/lodash"] pA_deps["node_modules (Project Root)"] -- Symlink --> pA_sym end class pA_sym,pA_hard,pA_deps project; subgraph ProjectB [Project B .pnpm virtual store] pB_sym["node_modules/lodash"] -- Symlink --> pB_hard["nested lodash@4.17.21/node_modules/lodash"] pB_deps["node_modules (Project Root)"] -- Symlink --> pB_sym end class pB_sym,pB_hard,pB_deps project; %% Cross-subgraph Hard Links to Global Store pA_hard --> |Hard link| pkgA pA_deps --> |Hard link to direct dep| pkgB pB_hard --> |Hard link| pkgA

Because it uses hard links, your project directories point to the exact same physical sectors on your disk. Installing a package in a second project takes milliseconds because no new files are written to disk.

Three Key Advantages

1. Massive Disk Space Savings

With pnpm, your dependencies take up space exactly once. If different projects use different versions of the same package, only the files that changed between versions are added to the store. If they use the exact same version, they share the same physical storage.

2. Elimination of Phantom Dependencies

pnpm creates a strict, nested node_modules layout. It only symlinks packages explicitly declared in your package.json into the root of your project’s node_modules.

Transitive dependencies are nested inside node_modules/.pnpm, keeping them hidden from Node’s module resolution algorithm. If you try to import a package that isn’t in your package.json, Node will fail immediately. This prevents fragile code that breaks unpredictably when sub-dependencies update.

Real-World Gotcha: Vite/Rollup Build Failures

Because pnpm blocks phantom dependencies, tools that dynamically generate virtual modules (like vite-plugin-pwa) may fail during build with errors like:

Rollup failed to resolve import "workbox-window" from "/@vite-plugin-pwa/virtual:pwa-register/vue"

This happens because the virtual module expects workbox-window to be hoisted to the root of node_modules. To fix it, you must explicitly declare the sub-dependency in your own project:

pnpm add -D workbox-window

3. Supercharged CI/CD Caching

Because pnpm keeps its global store isolated, caching in CI environments is incredibly simple and highly effective. In GitHub Actions, you can cache the pnpm store directly, reducing clean install times by up to 80%.

Here is a standard workflow utilizing actions/setup-node’s native pnpm cache integration:

name: CI

on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v6

      - name: Install pnpm
        uses: pnpm/action-setup@v4
        with:
          version: 9

      - name: Set up Node.js
        uses: actions/setup-node@v6
        with:
          node-version-file: .nvmrc
          cache: "pnpm"

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      - name: Run Lint
        run: pnpm lint

      - name: Run Format Check
        run: pnpm format:check

      - name: Run Type Check
        run: pnpm typecheck

      - name: Run Build
        run: pnpm build
        
      - name: Run tests
        run: pnpm test

How to Migrate to pnpm

Switching an existing project is straightforward and takes less than a minute.

  1. Install pnpm: Avoid installing pnpm globally via npm (e.g., npm install -g pnpm), as this ties it to a single Node version and causes it to disappear if you switch Node versions. Instead, use a standalone installation.

    Using the official standalone script:

    curl -fsSL https://get.pnpm.io/install.sh | sh -
    

    Using Homebrew (macOS/Linux):

    brew install pnpm
    
  2. Import your current lockfile: Generate a pnpm-lock.yaml file from your existing package-lock.json or yarn.lock to lock down the exact same dependency tree:

    pnpm import
    
  3. Clean up old artifacts: Remove the old node_modules directory and lockfile:

    rm -rf node_modules package-lock.json yarn.lock
    
  4. Perform a clean install:

    pnpm install
    
Handling Peer Dependency Warnings
Because pnpm enforces strict dependency resolution, you might see warning logs about missing peer dependencies that npm previously ignored silently. You can configure peer dependency rules in your package.json under pnpm.peerDependencyRules or configure .npmrc to auto-install peers if required.
Security Update: Ignored Build Scripts in pnpm v11+

Starting with pnpm v11, build and install scripts of dependencies (such as native binaries like esbuild) are blocked by default for security.

If you get a [ERR_PNPM_IGNORED_BUILDS] error, you must explicitly allow them. In a workspace/monorepo, you must define this in your pnpm-workspace.yaml (not package.json):

# pnpm-workspace.yaml
allowBuilds:
  esbuild: true
Vite Pre-bundling Cache Out of Sync

If you encounter a Vite: The file does not exist ... in the optimize deps directory error after configuring build scripts or upgrading dependencies, Vite’s internal cache is likely out of sync.

Fix it by forcing Vite to rebuild its cache:

pnpm run dev --force

Or manually clear the cache folder:

rm -rf node_modules/.vite

Conclusion

Switching to pnpm is a low-risk, high-return upgrade for any development workflow. It frees up storage on your workstation, guarantees that your dependency imports are safe and declared, and speeds up your builds.


Are you using pnpm or still stuck with traditional node_modules? Let me know on LinkedIn !

If you enjoyed this article, feel free to support my work on Ko-fi!

Support on Ko-fi