Caching Dependencies and Test Artifacts in CI
Every CI run that reinstalls dependencies from scratch pays a fixed tax — ninety seconds or more of network and disk time — on the critical path of every job and, when you shard, every shard. Caching removes that tax by restoring the last known-good node_modules and reusing expensive build outputs, but a cache is only safe when its key is designed so it hits when nothing relevant changed and misses the moment something did. This guide is for CI engineers running Vitest on GitHub Actions who want correct, fast caching without the class of bug where a stale cache makes CI pass or fail in ways you cannot reproduce locally. We cover cache-key design, the difference between a dependency cache and an artifact hand-off, warming the cache, and the stale-key failures that catch teams. Caching is the overhead-reducing lever in CI test orchestration that makes sharding worth doing.
Root Cause Analysis
The waste has two distinct sources that people often conflate. The first is dependency installation: npm ci resolves and downloads the same dependency tree on every run even though that tree only changes when the lockfile changes, which is rarely. The second is recomputation of derived state: transpiled TypeScript, a Vitest module graph, or a built package that is identical across runs unless its inputs changed. Both are pure functions of committed inputs, which is exactly what makes them cacheable — a cache is a memoisation keyed on those inputs.
The danger is a key that does not track the inputs faithfully. If the key is too coarse — a hand-maintained cache-v1 string — the cache serves a stale node_modules after a dependency bump, and tests fail against the wrong versions in a way that never reproduces on a developer machine that ran a fresh install. If the key is too fine — hashing every source file — the cache misses on every commit and buys nothing. The correct key hashes precisely the inputs that determine the cached output and nothing else: the lockfile for dependencies, the source and config for a build. Artifacts are a related but separate problem: a dependency cache is restored at the start of a job and is best-effort, while a test artifact must be reliably handed from a producing job to a consuming job, which is a guarantee, not an optimisation. Conflating the two is the root of most caching bugs. Merged coverage from sharded runs, for instance, is an artifact hand-off, which connects to enforcing coverage thresholds in a monorepo.
Reproducible Setup
The actions/setup-node action ships a built-in dependency cache keyed on the lockfile, which is the right default for most repositories. Reach for the lower-level actions/cache only when you need to cache something beyond the package manager’s store.
# the built-in cache: correct lockfile-hashed key, zero config
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm' # keys on package-lock.json automatically
- run: npm ci
// vitest.config.ts — enable the on-disk module cache worth persisting
import { defineConfig } from 'vitest/config';
export default defineConfig({
cacheDir: './.vitest-cache', // transformed modules, safe to cache
test: { reporters: ['default', 'json'] },
});
Implementation
Step 1 — Cache dependencies with a lockfile-hashed key. The key hashes the lockfile so any dependency change produces a new key and a guaranteed miss, while a restore-keys fallback lets an unrelated code change still restore the most recent compatible cache as a warm start.
- uses: actions/cache@v4
with:
path: |
node_modules
~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- run: npm ci
Step 2 — Cache derived state separately, keyed on its own inputs. Vitest’s transform cache and any build output are functions of source and config, not of the lockfile, so they get their own key. Mixing them into the dependency key would evict one whenever the other changed.
- uses: actions/cache@v4
with:
path: .vitest-cache
key: vitest-${{ runner.os }}-${{ hashFiles('src/**', 'vitest.config.ts') }}
restore-keys: vitest-${{ runner.os }}-
Step 3 — Hand test artifacts between jobs with upload/download, not cache. When a producing job must pass results to a consuming job — sharded coverage into a merge job, a JUnit report into a publish job — use artifacts, which are a reliable transfer, not a best-effort restore. This pattern is the backbone of sharding Vitest across GitHub Actions runners.
test:
steps:
- run: npx vitest run --coverage
- uses: actions/upload-artifact@v4
if: always() # publish even when tests fail
with: { name: coverage, path: coverage/coverage-final.json }
report:
needs: test
steps:
- uses: actions/download-artifact@v4
with: { name: coverage }
- run: node scripts/check-threshold.mjs
Step 4 — Warm the cache on the default branch. A pull-request cache restores from a restore-keys prefix, but the freshest prefix entry comes from whatever ran last. Populate a canonical cache by running the workflow on main so every PR restores from a known-good, recently built cache rather than a cold miss.
on:
push: { branches: [main] } # warms the cache other branches restore from
pull_request:
Step 5 — Set an eviction expectation, not a permanent store. GitHub evicts caches unused for seven days and enforces a per-repository size cap, so treat every cache as disposable. Never make correctness depend on a cache existing — npm ci must still succeed on a cold miss, just slower.
Verification
A cache is correct when it hits and misses for the right reasons, so verify both transitions explicitly rather than assuming a green build means a working cache. First, confirm a hit on an unrelated change: push a commit that edits only source, and check the run log shows Cache restored from key. Second, confirm a miss on a dependency change: bump a dependency, and check the log shows a cache miss followed by a fresh install and a new key saved.
# force the two transitions locally against the same key formula
git commit -am 'touch source only' # expect: cache HIT
npm ci
npm install -D some-package@latest # changes the lockfile
git commit -am 'bump dependency' # expect: cache MISS + new key
// scripts/assert-fresh-deps.ts — fail loudly if the cache served stale versions
import { readFileSync } from 'node:fs';
import { execSync } from 'node:child_process';
const lock = JSON.parse(readFileSync('package-lock.json', 'utf8'));
const expected = lock.packages['node_modules/vitest']?.version;
const installed = execSync('node -p "require(\'vitest/package.json\').version"')
.toString().trim();
if (expected && installed !== expected) {
throw new Error(`Stale cache: lockfile wants vitest ${expected}, installed ${installed}`);
}
console.log('Installed versions match the lockfile — cache is fresh.');
Wire the version-drift assertion into the pipeline right after npm ci. It is the single check that catches the worst caching bug — a stale node_modules that passes CI against the wrong dependency versions — before it wastes an afternoon of local debugging that will never reproduce the failure.
Troubleshooting
Tests pass in CI but fail locally, or vice versa. A stale dependency cache served an old node_modules that no longer matches the lockfile, so CI is running different code than a fresh install produces. The cause is a key that does not hash the lockfile — often a hand-versioned string. Switch the key to hashFiles('**/package-lock.json') and add the version-drift assertion above so a mismatch fails fast instead of silently.
The cache never hits. The key changes on every run because it hashes something volatile — a timestamp, a commit SHA, or a file the build itself writes. Inspect the resolved key in the run log and reduce the hash inputs to only the committed files that truly determine the output. If you cache a directory the build regenerates, exclude that directory from the hash.
The consuming job cannot find the artifact. A merge or publish job fails because the producing job uploaded nothing — usually because the upload step was skipped when tests failed, or the shard job was cancelled. Add if: always() to the upload step and set the matrix fail-fast: false so a failing producer still publishes its partial output. This is the same failure that breaks sharded coverage merges, so fixing it here fixes it there too, and it pairs with the containment approach in quarantining flaky tests in CI.
FAQ
Should I use the built-in setup-node cache or actions/cache?
Use the built-in cache: 'npm' on setup-node for dependencies — it already hashes the lockfile correctly and is the least error-prone option. Reach for the lower-level actions/cache only when you need to persist something the package manager does not own, such as a Vitest transform cache, a build output directory, or a Playwright browser download. Mixing custom caches into the dependency step tends to produce over-broad keys, so keep each cache scoped to one kind of input.
What is the difference between a cache and an artifact?
A cache is a performance optimisation restored best-effort at the start of a job and shared opportunistically across runs; if it is missing, the job must still succeed, just more slowly. An artifact is a reliable hand-off of a specific output from one job to another within the same workflow run, with a retention window you control. Use a cache for dependencies and derived state, and use an artifact whenever a later job genuinely needs a file an earlier job produced — sharded coverage, test reports, build bundles.
Why does my cache work on main but not on pull requests?
GitHub scopes cache access by branch: a pull-request branch can read caches from its base branch and its own branch, but not from unrelated branches. If only main runs the workflow, PRs restore from main’s cache through restore-keys, which is the intended warming pattern. If nothing runs on main, PRs have no warm cache to restore from and every first run is a cold miss, so add a push trigger on main to populate the canonical cache.
Can a bad cache cause a flaky test?
Yes, and it is an under-diagnosed cause. A cache that intermittently serves a stale dependency or a partially written build directory makes a test pass on a cache miss and fail on a stale hit, which reads exactly like nondeterminism. Before attributing an intermittent CI failure to the test, confirm the cache is version-consistent with the lockfile; if the failure disappears when you disable the cache, the cache key is the bug, not the test.
Related
- Back to Continuous Integration Test Orchestration
- Sharding Vitest across GitHub Actions runners — the artifact hand-off that merges per-shard coverage relies on this pattern.
- Running only tests affected by a change — affected selection reads a cached dependency graph to decide what to skip.
- Enforcing coverage thresholds in a monorepo — the coverage artifact this hand-off delivers to the threshold gate.
- Vitest vs Jest for CI speed — the runner whose install and transform costs caching removes.