Build the mental model
Every GitHub Actions job starts on a runner created fresh for that job and thrown away when it ends. Nothing survives between runs: no node_modules, no ~/.npm, no pip cache. That clean slate is deliberate, because it stops your build from quietly depending on leftover state from last week. The cost is that every run re-downloads the same dependency tree from the registry, and on most projects that download is the largest single slice of pipeline time.
actions/cache@v4 gives you a key-value store scoped to your repository. The step restores a directory before you install and saves it again at the end of a successful job. key is the exact identity of the entry; restore-keys is an ordered list of prefixes to fall back on when the exact key is not found.
Actions first looks for an entry whose key matches exactly; on a hit the directory is restored and the save at the end of the job is skipped, because cache entries are immutable. On a miss it walks restore-keys and takes the most recent entry whose key begins with that prefix. That is a partial hit: a warm but slightly out-of-date directory, which your package manager reconciles, and a new entry is then written under the exact key.
The key must contain a hash of the lockfile. This is correctness, not tuning. A constant key always hits, so the cache is restored forever, a newly added dependency is never installed, and CI goes green against a dependency tree that exists on no other machine. A too-loose cache is worse than no cache: no cache is merely slow, while a stale cache lies to you.
CACHE LOOKUP AND SAVE FLOW
--------------------------
restore step: key = <os>-npm-<hash of package-lock.json>
|
v
+--------------------------------+
| exact key exists in the cache? |
+--------------------------------+
| yes | no
v v
EXACT HIT +----------------------------+
directory restored | walk restore-keys prefixes |
cache-hit = true | "<os>-npm-" |
save at end SKIPPED +----------------------------+
| | match | none
| v v
| PARTIAL HIT MISS
| older entry empty directory
| cache-hit = false cache-hit = false
| | |
| +-------+--------+
| v
| install runs and reconciles
| v
| end of job: SAVE a new entry
| under the exact key
v
nothing saved (that key is already stored, entries are immutable)Connect it to a real scenario
Take a Next.js repo whose install step is the slowest part of CI. Add actions/cache@v4 before npm ci with path: ~/.npm, which is npm's download cache, not node_modules. Caching ~/.npm is safer because npm ci still reads package-lock.json and reconstructs node_modules deterministically from it; it just skips the network. Caching node_modules directly can restore native modules built for a different OS or Node version, which fails in ways that look nothing like a cache problem.
The key is the runner OS, a literal prefix, and hashFiles over the lockfile. runner.os is in there because a macOS runner and a Linux runner must never share an entry. The hash changes the moment anyone edits the lockfile, which is exactly the moment the cached download set becomes wrong.
restore-keys is a single prefix. On the run right after a dependency bump the exact key misses, the prefix matches yesterday's entry, and npm downloads only the packages that actually changed. A fresh entry is then saved under the new exact key.
One thing that trips teams up: cache entries are scoped per branch, with a fallback to the default branch. A feature branch can read main's cache, but two feature branches cannot read each other's, so a new branch usually gets a partial hit rather than an exact one.
Try the working example
name: CI with dependency cache
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Restore the npm download cache
id: npm-cache
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Install dependencies
run: npm ci
- name: Report whether the exact key was hit
run: echo "cache-hit=${{ steps.npm-cache.outputs.cache-hit }}"
- name: Run tests
run: npm test
The workflow runs one test job. On the first run for a given lockfile the lookup misses both the exact key and the prefix, npm ci downloads everything, and at the end of the job a new entry is saved under the exact key. On the next run with an unchanged lockfile the exact key hits, ~/.npm is restored before npm ci, the install completes without contacting the registry, and no save happens because that key already exists, so steps.npm-cache.outputs.cache-hit reports true. If someone changes package-lock.json the exact key no longer exists, restore-keys falls back to the newest entry matching the prefix, npm ci downloads only the difference, cache-hit is false, and a new entry is written. The job's pass or fail result never depends on the cache; only how much is downloaded does.5-minute try-it
Change your cache key to a constant such as the OS name alone, with no hashFiles, then push a commit that adds one new dependency to package.json and package-lock.json. Watch what npm ci does. Restore the hashFiles key and explain in one sentence why the constant key is a correctness bug rather than a performance choice. Then add a second cache step for your build tool's own cache directory and give it its own independent key.
One important caution
A cache key with no lockfile hash, such as the OS name plus a fixed word: it hits on every run, so a newly added dependency is never installed and CI passes against a dependency tree that exists nowhere else.
Caching node_modules instead of the package manager's download directory: restored native modules compiled for a different OS or Node version fail with errors that look nothing like a cache problem.
GitHub Docs - Caching dependencies to speed up workflows — CI/CD with GitHub Actions