A build that passes in CI and fails on a fresh machine, or the other way round, often has a cache somewhere in the middle. Caches are there to save time: a cold npm ci on a mid-sized app might spend a minute or two downloading packages, and a warm one a few seconds. But a cache is also a hidden input to the build. If the cache can make the result different from a clean build, it’s no longer an optimisation, and you’ve got a bug that only shows up sometimes.
Interviewers ask about CI caching to see whether you separate the two ideas: what defines the build (source code, lockfile, toolchain versions) and what merely speeds it up. The rule that follows is short. The cache may change how fast you get a result. It must never change the result.
Inputs versus accelerators
For a Node project, the build’s dependency input is package-lock.json. It names every package and exact version, with an integrity hash for each. npm ci installs exactly that, and fails if package.json and the lockfile disagree. That’s what makes the install reproducible.
The npm cache (~/.npm on Linux and macOS) is a content-addressed store of downloaded tarballs. If the tarball for express@4.21.2 is already there, npm verifies its integrity hash and skips the download. If it’s not, npm fetches it. Either way you get the same files, because the lockfile decides what’s installed and the integrity hashes check it. A stale or partial download cache can only make the install slower.
Compare that with caching node_modules itself, or a dist/ folder, or a compiler’s incremental state. Those are outputs. Restore them from the wrong key and the build may skip work and use whatever was restored, which is how stale code reaches production.
Why caching node_modules with npm ci does nothing
A common pipeline restores a cached node_modules and then runs npm ci. But npm ci deletes an existing node_modules before installing. A quick check with npm 10:
$ mkdir -p node_modules/restored-from-cache
$ ls node_modules
restored-from-cache
$ npm ci --offline
up to date, audited 1 package in 491ms
found 0 vulnerabilities
$ ls -a node_modules
.
..
.package-lock.jsonThe restored directory is gone. The pipeline spent time uploading and downloading a cache that was thrown away. Cache the npm store instead. In GitHub Actions, actions/setup-node does that for you:
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm # caches ~/.npm, keyed on the lockfile hash
- run: npm ciTeams that cache node_modules usually do so to skip npm ci entirely when the cache hits. That can be faster, but now the cached directory is a build output, and the key has to cover everything that affects it, which brings us to keys.
A key must cover every input
A cache key is a string. On an exact match you get that cache. The key should change whenever anything that affects the cached content changes. For a dependency cache, that’s at least the operating system, the runtime’s major version and the lockfile:
#!/bin/sh
os=$(uname -s)
node_major=$(node -p 'process.versions.node.split(".")[0]')
lock=$(shasum -a 256 package-lock.json | cut -c1-12)
echo "npm-$os-node$node_major-$lock"$ ./key.sh
npm-Darwin-node22-00acc8b95c82
$ sed -i '' 's/4.21.2/4.21.3/' package-lock.json # bump one dependency
$ ./key.sh
npm-Darwin-node22-01caeeeec0f3In GitHub Actions the same key is usually written as ${{ runner.os }}-node22-${{ hashFiles('**/package-lock.json') }}.
Leaving out the runtime version is a classic mistake with cached node_modules. Packages with native add-ons, such as bcrypt or sharp, compile against a specific Node ABI. Node 22 reports process.versions.modules as 127; Node 20 uses 115. Restore a node_modules built on Node 20 into a Node 22 job and the first require of that add-on fails with an error saying it “was compiled against a different Node.js version using NODE_MODULE_VERSION 115.” The cache key said nothing had changed, but an input had.
The same thinking applies to other ecosystems. A Gradle cache depends on the Gradle version and the build files. A pip cache depends on the Python version and requirements.txt or the lockfile. A Docker layer cache depends on the base image digest, which is why FROM node:22 with layer caching can keep an old base image long after the tag moved.
restore-keys: useful for downloads, risky for outputs
actions/cache accepts a list of fallback prefixes:
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-node22-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-node22-If the exact key misses, the action restores the most recently created cache whose key starts with the prefix. For the npm store that’s great: after a one-package bump, you get yesterday’s store, npm downloads the one new tarball, and at the end of the job a new cache is saved under the new exact key. Nothing about the result depends on what was restored.
For build outputs it’s dangerous. An incremental compiler or test cache restored from a different commit can contain results for files that have since changed. Many tools handle this correctly by hashing their inputs (Gradle’s build cache, Bazel, Turborepo, Nx), and then a partial restore is safe because the tool rechecks every entry. Tools that rely on file timestamps, or a dist/ folder that your script skips rebuilding when it exists, aren’t safe. If you can’t explain how the tool detects a stale entry, don’t give it a partial restore.
The output cache-hit is true only for an exact key match. Scripts that skip npm ci when the cache was restored should check that, not just whether a directory exists.
Who can write to the cache
A cache is shared state, so it’s also a security boundary. In GitHub Actions, caches are scoped by branch: a workflow can restore caches created on its own branch or on the default branch, but pull request runs can’t write into the default branch’s cache. That stops a malicious PR from planting a poisoned dependency for main to pick up.
The scoping breaks down when untrusted code runs in a privileged context, such as a pull_request_target workflow that checks out and builds the PR’s code. That job runs with the base branch’s permissions, so whatever it saves can be restored by later main builds. The guide on CI secrets and untrusted contributions covers that pattern.
GitHub also limits total cache storage per repository (10 GB by default) and evicts caches that haven’t been used for 7 days, so don’t rely on a cache existing. The build must always work, just slower, with an empty cache.
Checking that the cache isn’t changing results
Two habits keep caches honest:
- Run a clean build regularly. A scheduled nightly job with caching turned off proves the pipeline works from inputs alone. If it fails while cached builds pass, the cache was hiding something.
- Compare outputs. For important artifacts, build once with a warm cache and once cold, and compare checksums or at least test results. If they differ, find out why before trusting the cache.
Interview practice
Your Node service’s CI restores node_modules from a cache keyed on ${{ runner.os }}-modules, and skips npm install if the directory exists. After a teammate updated package-lock.json to fix a security vulnerability, CI stayed green but a scan of the deployed artifact still reported the vulnerable version. Explain, and fix the pipeline.
Answer
The key only includes the runner OS, so it never changes when the lockfile does. Every run restored the same old node_modules, the directory existed, so the install step was skipped and the build used the vulnerable package. Tests passed because the vulnerable version still worked. The lockfile said one thing and the artifact contained another, which is exactly what a cache must never cause.
The simplest fix is to stop caching node_modules. Use actions/setup-node with cache: npm, which caches the npm download store keyed on the lockfile hash, and always run npm ci. That installs exactly what the lockfile specifies, and the cache only saves download time. If install time really is a bottleneck and you want to keep caching node_modules, the key must include the OS, the Node major version and hashFiles('**/package-lock.json'), with no restore-keys fallback, and the install step should be skipped only when cache-hit is true. Either way, add a scheduled clean build and run the dependency scan in CI against the built artifact, so the next mismatch fails a check instead of reaching production.