GitHub Actions Cache node_modules — When It Hurts CI
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,
arm64vsx64) - Node ABI version (native addons rebuilt for
node@20vsnode@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 dimension | Must be in cache key? |
|---|---|
runner.os | Yes |
| Node version | Yes |
| Architecture | Yes if you mix |
| lockfile hash | Yes |
NODE_ENV | Usually 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 +
ciexceeds budget - Pure JS trees with zero native addons (verify with
npm ls/ nonode-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”
- Diff the cache key components in the Actions log.
- Check whether
postinstallran (look for skipped scripts). - Delete the cache entry (GH UI or change the key prefix) and re-run clean.
- Compare
npm ls/pnpm listbetween 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.