Thuta Learning
IntermediateDevOps & Toolsbeginner

Caching Dependencies

What you'll walk away with

  • Explain the core ideas behind Caching Dependencies
  • Read the diagram and trace how the pipeline flows and what gates each stage
  • Read the workflow file and predict which jobs run, in what order, and what gates them

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.

text
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

yaml
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
You should see
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 workflowsCI/CD with GitHub Actions

Easy traps

  • 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.
  • Validate a workflow on a branch or test repository before pointing it at a production deployment.

Exercise

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.

You'll know it worked when: 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.

Caching Dependencies | Thuta Learning