ABHIJAT
← Back to Writing

DevOps

Caching node_modules correctly, and cutting CI time in half

The default GitHub Actions cache setup for a Node project usually isn't wrong, just incomplete in a way that quietly halves its usefulness.

Abhijat2026-073 min read5 views

Last updated September 17, 2026

The actions/setup-node cache option caches npm's download cache, not node_modules itself — which still saves the download step but not the install step's actual work of resolving and linking packages. For a project with a large dependency tree, that gap is most of the time you were trying to save.

Caching node_modules directly

- uses: actions/cache@v4
  with:
    path: node_modules
    key: node-modules-${{ hashFiles('package-lock.json') }}

The key detail is the cache key: it's derived from a hash of the lockfile, not a fixed string. A fixed key means the cache never invalidates when dependencies change, which is worse than no cache — it silently serves stale node_modules against a newer lockfile. Hashing the lockfile means the cache key changes exactly when the dependency tree changes, and not otherwise.

Confirm it's actually hitting

A cache step that exists doesn't guarantee it's helping — check the action's own log output for "Cache restored from key" versus "Cache not found," and compare install step duration across a cache hit and a cache miss run. It's easy to add a caching step, watch the workflow pass, and never notice it's missing every time because the key is subtly wrong (a typo in the hash expression, or hashing the wrong lockfile path in a monorepo with multiple package-lock.json files).

The other detail worth getting right: npm ci still needs to run even with a warm cache, since it verifies the lockfile against node_modules rather than trusting it blindly — the cache saves the expensive dependency resolution and download work, not the verification step, which is fast either way.

Tags

GitHub ActionsCI/CDNode.js