Best Practices for Caching npm vs Yarn vs pnpm in CI

Choose the wrong cache directory or omit the lockfile hash and every CI run pays full network cost β€” this page gives you the exact paths, key patterns, and frozen-install commands for each package manager.

When to use this pattern

  • Your pipeline’s npm ci or equivalent accounts for more than 30 seconds of total run time on a warm runner.
  • You are migrating a project from one package manager to another and need correct cache isolation to prevent cross-contamination between branches.
  • You run multi-stage CI/CD pipelines for React apps where multiple jobs need the same node_modules without re-downloading from the registry.

Prerequisites


Cache directory and key strategy per package manager Diagram comparing npm, Yarn Classic, Yarn Berry, and pnpm cache directories, lockfiles, and frozen install commands used in CI pipelines. Package manager Lockfile Cache directory Frozen install command npm 9+ package-lock.json ~/.npm npm ci Yarn Classic (v1) yarn.lock ~/.cache/yarn/v6 yarn install --frozen-lockfile Yarn Berry (v4+) yarn.lock .yarn/cache yarn install --immutable pnpm 8+ pnpm-lock.yaml ~/.local/share/ pnpm/store/v3 pnpm install --frozen-lockfile npm ci does not accept --frozen-lockfile β€” it enforces lockfile parity automatically. Never cache node_modules directly. Always include runner.os and runner.arch in the cache key when native modules (node-gyp, sharp, canvas) are in the dependency graph.

Complete working example

The block below is a single GitHub Actions workflow covering npm, Yarn Classic, Yarn Berry, and pnpm. Pick the job for your package manager; the key structure is identical across all four.

# .github/workflows/install-cache.yml
name: Install with cache

on: [push, pull_request]

jobs:
  # ── npm ──────────────────────────────────────────────────────────────────
  install-npm:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Cache npm store
        uses: actions/cache@v4
        with:
          # Cache the global npm store, NOT node_modules.
          path: ~/.npm
          # Primary key: OS + arch + exact lockfile hash.
          key: ${{ runner.os }}-${{ runner.arch }}-npm-${{ hashFiles('**/package-lock.json') }}
          # Fallback: accept any npm cache for this OS/arch pair (partial hit).
          restore-keys: |
            ${{ runner.os }}-${{ runner.arch }}-npm-

      - name: Install dependencies
        # npm ci reads package-lock.json and fails if it is missing or inconsistent.
        run: npm ci

  # ── Yarn Classic ─────────────────────────────────────────────────────────
  install-yarn-classic:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: yarn            # setup-node can handle Yarn Classic cache natively

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

  # ── Yarn Berry (v2+) ─────────────────────────────────────────────────────
  install-yarn-berry:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Cache Yarn Berry store
        uses: actions/cache@v4
        with:
          # Berry stores packages inside the repo under .yarn/cache (zero-installs mode)
          # or a global path when using node-modules linker. Adjust if you use a custom cacheFolder.
          path: .yarn/cache
          key: ${{ runner.os }}-${{ runner.arch }}-yarn-berry-${{ hashFiles('**/yarn.lock') }}
          restore-keys: |
            ${{ runner.os }}-${{ runner.arch }}-yarn-berry-

      - name: Install dependencies
        # --immutable is Berry's equivalent of --frozen-lockfile.
        run: yarn install --immutable

  # ── pnpm ─────────────────────────────────────────────────────────────────
  install-pnpm:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20

      - name: Install pnpm
        uses: pnpm/action-setup@v3
        with:
          version: 8

      - name: Get pnpm store path
        # pnpm store path can vary by OS; read it dynamically rather than hard-coding.
        id: pnpm-cache
        run: echo "store=$(pnpm store path)" >> $GITHUB_OUTPUT

      - name: Cache pnpm store
        uses: actions/cache@v4
        with:
          path: ${{ steps.pnpm-cache.outputs.store }}
          key: ${{ runner.os }}-${{ runner.arch }}-pnpm-${{ hashFiles('**/pnpm-lock.yaml') }}
          restore-keys: |
            ${{ runner.os }}-${{ runner.arch }}-pnpm-

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

Equivalent GitLab CI configuration (npm)

# .gitlab-ci.yml
variables:
  NPM_CACHE_DIR: "$CI_PROJECT_DIR/.npm-cache"

install:
  image: node:20-alpine
  cache:
    key:
      # GitLab uses files: to build a hash from the lockfile contents.
      files:
        - package-lock.json
      # Prefix with an OS token so macOS/Linux caches don't collide.
      prefix: "linux"
    paths:
      - .npm-cache/
    # pull-push: restore at job start, upload at job end.
    policy: pull-push
  script:
    # Point npm at the project-local cache dir so GitLab's artifact path works.
    - npm ci --cache .npm-cache --prefer-offline

Equivalent CircleCI configuration (pnpm)

# .circleci/config.yml
version: 2.1

jobs:
  install:
    docker:
      - image: cimg/node:20.0
    steps:
      - checkout
      - run:
          name: Install pnpm
          command: npm install -g pnpm@8

      - restore_cache:
          keys:
            # Exact match on the lockfile hash.
            - v1-pnpm-{{ checksum "pnpm-lock.yaml" }}
            # Fallback: accept any pnpm cache (will run frozen-lockfile install over it).
            - v1-pnpm-

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

      - save_cache:
          key: v1-pnpm-{{ checksum "pnpm-lock.yaml" }}
          paths:
            - ~/.local/share/pnpm/store

Step-by-step walkthrough

Cache path selection. The path value must point to the package manager’s global content store, not to node_modules. The global store is keyed by package content hash, so restoring it gives npm ci / --frozen-lockfile everything it needs to link packages without re-downloading. Caching node_modules instead bypasses integrity checks and breaks native add-ons compiled for a different architecture.

Primary key construction. The key combines three tokens: runner OS (runner.os), runner architecture (runner.arch), and a hashFiles() digest of the lockfile. Including runner.arch is non-negotiable for projects that contain native modules such as sharp, canvas, or node-gyp-compiled packages β€” x64 binaries silently fail on ARM runners if the cache key doesn’t isolate by architecture. Linking to how environment matrices in GitHub Actions are configured gives you the right variable names for multi-platform builds.

Restore-key fallback. The restore-keys prefix (e.g., ubuntu-22.04-x64-npm-) lets the runner accept a partial hit from a previous lockfile hash. The subsequent frozen-install command handles the delta, fetching only the newly added or changed packages. Without a fallback, every lockfile change causes a full cold install.

Frozen install after restore. Regardless of whether the cache hit was full or partial, always run npm ci, yarn install --frozen-lockfile / --immutable, or pnpm install --frozen-lockfile. These commands validate that the restored store matches the lockfile exactly and fail fast if they don’t, preventing silent dependency drift. This is a core part of sound artifact management strategies for frontend builds.

Dynamic pnpm store path. pnpm’s store location varies across Linux distributions and can be overridden by PNPM_HOME. Read it at runtime via pnpm store path and pass the output to the cache step, rather than hard-coding ~/.local/share/pnpm/store/v3.

GitLab cache policy. Setting policy: pull-push on the cache block means the job restores before script: runs and uploads afterwards. Use policy: pull on downstream jobs that only read the cache (e.g., a separate build job) to avoid redundant uploads and cut pipeline duration by 5–15 seconds per job.

Verification

Run these commands in a pipeline step immediately after install to confirm the cache is operating correctly:

# 1. Confirm the frozen install exited cleanly.
echo "Exit code: $?"

# 2. Print store size β€” use this as your baseline for the 15 % drift check.
du -sh ~/.npm        # npm
du -sh ~/.cache/yarn # Yarn Classic
pnpm store path | xargs du -sh   # pnpm

# 3. Check that node_modules matches the lockfile (npm only).
npm ls --depth=0

# 4. For pnpm: verify store integrity (runs content hash checks on cached packages).
pnpm store status

Expected output for a full cache hit in GitHub Actions:

Cache restored successfully
Run npm ci
added 0 packages in 1.8s

If you see added N packages with N > 0 on a supposed full hit, the cache key is stale β€” check that the lockfile hash in the key matches the committed lockfile.

Common pitfalls

Native module cache collisions across OS/arch

Symptom: Cache restore succeeds but npm ci immediately fails with ENOENT: no such file or directory inside a .node add-on path, or Error: /lib/x86_64-linux-gnu/libc.so.6: version 'GLIBC_2.34' not found.

Cause: A cache built on ubuntu-20.04 was restored on ubuntu-22.04, or an x64 cache hit on an ARM runner. The binary .node files in the store are architecture-specific.

Fix: Add runner.arch to every cache key and ensure the restore-key prefix also includes it:

key: ${{ runner.os }}-${{ runner.arch }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
  ${{ runner.os }}-${{ runner.arch }}-npm-

After applying this fix, invalidate the old cache manually (rename the v prefix from v1 to v2) so runners don’t fall back to the bad cross-arch entry.

Unbounded store growth exhausting runner disk quota

Symptom: CI fails with No space left on device or GitLab CI reports cache upload failed: 413 Request Entity Too Large.

Cause: The pnpm or npm cache accumulates every package version ever installed across branches. Without eviction, the store can reach several gigabytes on long-lived self-hosted runners.

Fix: Add a pre-install cleanup step that prunes the store, and cap cache size for GitLab:

# pnpm: remove packages not referenced by any local project
pnpm store prune

# npm: trim the cache to the most recent version of each package
npm cache verify

For GitLab CI, add cache: max_fallback_size: 1G under the job’s cache block (GitLab 15.0+).

Also check whether node_modules is accidentally included in the cache paths alongside the store β€” this is the most common cause of 3–5Γ— oversized caches.

Stale cache after lockfile update in a monorepo

Symptom: After a developer adds a package and updates the lockfile, CI still installs the old set of packages. The install exits 0 but the new package is missing at runtime.

Cause: The cache key hashes only the root package-lock.json, but the modified lockfile lives in a workspace sub-directory.

Fix: Use a glob pattern that captures all workspace lockfiles:

key: ${{ runner.os }}-${{ runner.arch }}-npm-${{ hashFiles('**/package-lock.json') }}

The ** glob in hashFiles covers nested paths. For a mixed-manager monorepo (npm at root, pnpm in packages), hash all lockfiles in the same key:

key: ${{ runner.os }}-${{ runner.arch }}-deps-${{ hashFiles('**/package-lock.json', '**/pnpm-lock.yaml') }}

This pairs naturally with the remote caching strategies in Turborepo, where the build cache and the dependency cache use complementary but independent keys.


FAQ

Should I cache node_modules or the package manager store?

Always cache the global store (~/.npm, ~/.cache/yarn/v6, ~/.local/share/pnpm/store). Caching node_modules bypasses integrity checks, breaks native module compilation for different OS or architecture targets, and typically inflates cache size by 3–5Γ— because each project gets its own copy of every package rather than sharing a content-addressed store.

How do I handle cache invalidation in a monorepo with multiple lockfiles?

Use a composite hash key that covers all workspace lockfiles: hashFiles('packages/**/package-lock.json', 'pnpm-lock.yaml'). GitHub Actions hashFiles accepts multiple glob patterns and combines them into a single SHA256 digest. Alternatively, maintain a root-level lockfile that aggregates all workspace dependencies and hash only that file β€” this simplifies the key but requires your tooling to keep the root lockfile in sync. This overlaps with incremental build and affected detection patterns in monorepos, where accurate change detection depends on the same lockfile hygiene.

Why does pnpm cache perform better than npm or Yarn Classic in CI?

pnpm uses a content-addressable store with hardlinks. Identical package versions across projects share the same disk blocks, so cache extraction is fast and store growth is sub-linear as projects multiply. npm and Yarn Classic duplicate packages per project β€” node_modules/react appears once per project even if ten projects use the same version. The net effect on a typical frontend project is a 40–60 % reduction in store size compared to npm, and 10–20 % faster install times after a partial cache hit.

How do I verify the cache is actually being used and not just restored?

Check the install command’s output line added N packages. On a full cache hit, npm prints added 0 packages in Xs. For pnpm, look for Already up to date with an elapsed time under 3 seconds. If the CI platform exposes cache hit/miss metadata (GitHub Actions sets cache-hit output on actions/cache), log it explicitly:

- name: Cache npm store
  id: npm-cache
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-${{ runner.arch }}-npm-${{ hashFiles('**/package-lock.json') }}

- name: Log cache status
  run: echo "Cache hit: ${{ steps.npm-cache.outputs.cache-hit }}"

← Back to Artifact Management Strategies for Frontend Builds