GitHub Actions Cache node_modules — When It Hurts CI

GitHub Actions CI npm

Someone on r/devops posted a 40% CI speedup from caching node_modules. A week later half their matrix jobs fail with missing native bindings, and npm ci is somehow both slower and nondeterministic. Caching node_modules is not free horsepower. It is a trade that often loses against actions/setup-node cache for the package manager store.

What goes wrong with node_modules caches

node_modules is not a pure function of the lockfile alone. It also depends on:

  • OS and CPU architecture (Linux vs macOS runners, arm64 vs x64)
  • Node ABI version (native addons rebuilt for node@20 vs node@22)
  • Optional dependencies resolved for the runner platform
  • Scripts that patch files postinstall (patch-package, husky, native compile)

Restore a Ubuntu node_modules tarball onto a job that changed Node version, and you get mysterious ENOENT or invalid ELF header failures. Restore across npm majors and you get phantom packages.

Prefer caching the package manager content-addressable store

- uses: actions/setup-node@v4
  with:
    node-version: "22"
    cache: "npm"   # or yarn / pnpm
- run: npm ci

That caches ~/.npm (or pnpm store) keyed by lockfile hash. npm ci still runs, still verifies the tree, still rebuilds natives for this runner — but downloads are warm. It is slightly slower than a perfect node_modules hit and far less cursed.

pnpm:

- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
  with:
    node-version: "22"
    cache: "pnpm"
- run: pnpm install --frozen-lockfile

If you insist on caching node_modules

Key by everything that invalidates binaries:

- uses: actions/cache@v4
  with:
    path: node_modules
    key: node-modules-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-${{ matrix.node }}
    restore-keys: |
      node-modules-${{ runner.os }}-

Then still run a verify step:

- run: npm ci --ignore-scripts=false
# or at least: npm rebuild

Blindly skipping npm ci when the cache hits is how you ship CI that never saw a clean install.

Matrix builds make bad keys worse

Matrix dimensionMust be in cache key?
runner.osYes
Node versionYes
ArchitectureYes if you mix
lockfile hashYes
NODE_ENVUsually yes if it changes optional deps

One shared node_modules key across Node 18 and 22 jobs is a classic footgun.

When node_modules cache is actually justified

  • Monorepo with enormous install graphs where even a warm store + ci exceeds budget
  • Pure JS trees with zero native addons (verify with npm ls / no node-gyp)
  • Single OS, single Node version, no matrix

Even then, measure. GitHub’s cache download + unpack can exceed a warm npm ci for medium projects.

Debugging “works on main, fails on PR”

  1. Diff the cache key components in the Actions log.
  2. Check whether postinstall ran (look for skipped scripts).
  3. Delete the cache entry (GH UI or change the key prefix) and re-run clean.
  4. Compare npm ls / pnpm list between failing job and local Docker matching the runner image.

Install time vs correctness

Measure both. A warm npm ci with setup-node store cache is often good enough at 40–90 seconds. Saving twenty seconds by caching node_modules is not worth a week of native-addon flakes. If install dominates wall clock, try pnpm + store cache first — content-addressable stores invalidate cleanly when the lockfile changes without carrying stale compiled binaries across Node upgrades.

Unpopular opinion: default to lockfile + setup-node store cache. Treat node_modules caching as an advanced optimization with an explicit invalidation story — not the first lever you pull for green badges.