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 cior 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_moduleswithout re-downloading from the registry.
Prerequisites
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-lockfileEquivalent 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-offlineEquivalent 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/storeStep-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 statusExpected 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 verifyFor 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 }}"Related
- Artifact Management Strategies for Frontend Builds β parent guide covering build output retention, versioning, and cross-job artifact sharing.
- CI/CD Pipeline Architecture & Fundamentals β pillar covering pipeline topology, stage ordering, and concurrency management.
- Implementing Remote Build Caching with Turborepo β extends dependency caching with distributed task-output caching for monorepo build steps.
- Optimizing Pipeline Concurrency and Queue Limits β how dependency-install caching interacts with runner concurrency and queue wait times.
β Back to Artifact Management Strategies for Frontend Builds