Guides / Fix your GitHub Actions cache

Recomputing the same work

Fix your GitHub Actions cache: misses, thrash, and key design

By Keith Mazanec, Founder, CostOps · Updated February 17, 2026

Why your GitHub Actions cache is broken

GitHub Actions caching fails in two ways: cache misses, where the cache never restores at all, and cache thrash, where the cache appears to work but invalidates every run, making CI slower and more expensive than having no cache at all. Both problems come down to cache key design. The fix is to key on lockfile hashes instead of commit SHAs, add layered restore keys, split dependency and build caches, and warm the default branch so every PR starts hot.

A developer opens a PR. GitHub Actions spins up a runner, clones the repo, and runs npm ci. It downloads 1,200 packages from scratch. The job takes 8 minutes. The next push does the same thing. So does every other PR opened that day. You configured actions/cache months ago, but your cache hit rate is near zero, and you never noticed because cache misses are silent.

This is one of the most common GitHub Actions cost problems, and it usually comes down to three things: bad cache keys, misunderstood branch scoping rules, or eviction that wipes entries before they get reused. Worse, some teams have caches that appear to work but actually make things slower: the key changes every commit, so every run uploads a new 400 MB entry, downloads a stale one via restore-keys, and still runs a full install. That's cache thrash, and it costs more than having no cache at all.

Symptoms

How to tell if your cache is broken or thrashing

GitHub Actions doesn't fail a job when caching breaks. Instead, it silently falls through to a cold install. Cache thrash is even harder to spot because entries are saved and restored, but the restored data doesn't help. Look for these symptoms:

  • Low or zero cache hit rate. Expand the actions/cache step in your workflow logs. If it says Cache not found for input keys on most runs, your key pattern is wrong or your caches are being evicted before reuse.

  • Cache restore followed by full install. If the logs say Cache restored from key but npm ci or pip install still takes the same time as a cold run, you're restoring stale data. The cache hit is an illusion because it matched a prefix via restore-keys but the content is too outdated to help.

  • Consistent install times across runs. If npm ci, pip install, or bundle install takes the same amount of time on every run, even when dependencies haven't changed, nothing is being cached. A working cache should reduce install time by 50–90%.

  • First PR run always cold. Every new pull request starts with a full dependency install, even though main just ran the same workflow with the same lockfile. This points to a branch scoping issue where your PR can't access the cache created by main because the key doesn't match.

  • Cache size near the 10 GB limit. Check your repository's cache usage under Settings → Actions → Caches. If you're at or near 10 GB with hundreds of entries, you're creating too many unique cache keys. GitHub evicts the least recently used entries first, potentially including caches your workflows depend on.

  • Build failures that disappear on re-run. Stale caches can restore outdated build artifacts, incompatible dependency versions, or corrupted state. If your CI fails with cryptic errors that vanish when you re-run the job (or clear the cache), a broad restore-keys pattern is likely restoring data from a different context.

Metrics

What broken caching costs you

Dependency installation is often the single largest chunk of CI runtime. When caching works, it eliminates that chunk. When it misses, you pay for a full install on every run. When it thrashes, you pay for a full install plus cache upload and download overhead. Here's what that looks like for a team running 40 CI jobs per day on Linux:

Thrashing cache (worst)

Minutes/run 14
Monthly minutes 12,320
Monthly cost $74/mo

Includes 2 min cache I/O overhead per run

No cache at all

Minutes/run 12
Monthly minutes 10,560
Monthly cost $63/mo

Full install every run, no I/O overhead

Properly keyed cache

Minutes/run 7
Monthly minutes 6,160
Monthly cost $37/mo

Save $37/mo · $444/year · per workflow

At $0.006/min (Linux 2-core), 40 runs/day. A thrashing cache is $11/month worse than no cache at all because it adds upload and download overhead on every run without saving any install time. On macOS runners at $0.062/min, the same pattern costs $109/mo more than no cache. Fix the keys and you save $37/mo on Linux or $444/year per workflow.


Fix 1

Stop using commit SHAs in cache keys

The most common cache misconfiguration is using github.sha, github.run_id, or github.run_number in the cache key. Since these change on every push, you get a unique key every time, which means a cache miss every time. The cache is created but never restored by exact match. A repository running 40 jobs/day with SHA-based keys creates 40 new cache entries daily, filling the 10 GB repo limit within days and evicting the caches that matter.

The correct pattern keys on the OS and the hash of your lockfile. The cache only changes when dependencies actually change. Between dependency updates, every run gets an exact match.

New key every push - thrashes
- uses: actions/cache@v4
  with:
    path: node_modules
    key: ${{ runner.os }}-${{ github.sha }}
    restore-keys: |
      ${{ runner.os }}-

# Key changes every commit
# restore-keys matches any OS entry
# → restores stale data every time
# → saves a new 400 MB entry every time
Stable key - invalidates on dep change
- uses: actions/cache@v4
  with:
    path: node_modules
    key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-

# Key only changes when lockfile changes
# → exact hit on most runs
# → prefix match after dep update

One caveat: hashFiles() runs against the working directory, so it must come after actions/checkout. If you place the cache step before checkout, hashFiles() returns an empty string, silently producing the same key for every run regardless of whether dependencies changed. No error is raised.

Also avoid using hashFiles('**/package-lock.json') with the recursive glob. After npm ci runs, nested package-lock.json files inside node_modules can match the glob, producing a different hash at save time than at restore time. Reference the lockfile directly: hashFiles('package-lock.json').

Fix 2

Add layered restore keys for partial cache hits

When a developer adds a new dependency, the lockfile hash changes and the exact cache key no longer matches. Without restore keys, the runner falls back to a full install from scratch. With restore keys, it finds the closest previous cache and only installs the delta.

Restore keys are evaluated as prefixes, in order, and the most recently created match wins. Order them from most specific to least specific. But make the prefixes specific enough to match only caches from the same context. A broad prefix like ${{ runner.os }}- matches any cache for that OS, including entries from different workflows or dependency managers. Scope it to ${{ runner.os }}-node- so it only matches relevant entries.

.github/workflows/ci.yml
- uses: actions/checkout@v4

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

- run: npm ci

Here's how the lookup works: GitHub first tries the exact key (Linux-node-abc123). If the lockfile changed, that misses. It then tries the prefix Linux-node-, which matches any previous Node cache for this OS. The restored node_modules is stale but usable, so npm ci only needs to fetch the diff, which is typically a few seconds instead of a few minutes.

For matrix builds, include the matrix variable in both the key and the restore-key prefix to avoid cross-version collisions. Without it, a Node 18 job may restore a cache created by a Node 20 job, causing native module incompatibilities:

key: ${{ runner.os }}-node-${{ matrix.node-version }}-${{ hashFiles('package-lock.json') }}
restore-keys: |
  ${{ runner.os }}-node-${{ matrix.node-version }}-
  ${{ runner.os }}-node-

One caveat: don't over-scope restore keys either. If your restore-key is identical to your primary key minus the hash, a single prefix is correct. Adding more layers just widens the blast radius without improving hit rates.

Fix 3

Split dependency and build caches into separate entries

Use separate cache entries for dependencies and build artifacts. Dependencies change rarely (when the lockfile changes). Build artifacts change on every commit (when source code changes). A single cache entry that bundles both invalidates on every push, reproducing the thrash pattern even with a good cache key.

.github/workflows/ci.yml
# Cache 1: Dependencies (changes when lockfile changes)
- uses: actions/cache@v4
  with:
    path: node_modules
    key: ${{ runner.os }}-deps-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-deps-

- run: npm ci

# Cache 2: Build output (changes when source changes)
- uses: actions/cache@v4
  with:
    path: .next/cache
    key: ${{ runner.os }}-nextjs-${{ hashFiles('src/**') }}
    restore-keys: |
      ${{ runner.os }}-nextjs-

- run: npm run build

The dependency cache hits on every run that doesn't change package-lock.json, which is most runs. The build cache invalidates more frequently but is smaller and restores a partial build state that still speeds up the next build. Each cache does its job without poisoning the other.

One caveat: some build tools produce caches that become stale over time. The Next.js team has documented that a stale .next/cache can make builds 30% slower than a clean build. If your build cache grows unbounded or build times increase over time, add a time-based component to the key (e.g., the current week number) to force periodic invalidation.

Fix 4

Replace manual cache config with setup action caching

GitHub's official setup-* actions have built-in caching that handles key generation, path detection, and restore logic automatically. One parameter replaces 5+ lines of cache configuration. This eliminates most key misconfiguration issues.

Manual cache (error-prone)
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
  with:
    node-version: 20

- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-npm-

- run: npm ci
Built-in caching (one parameter)
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: 'npm'

- run: npm ci

This works for Node (setup-node with cache: 'npm', 'yarn', or 'pnpm'), Python (setup-python with cache: 'pip'), Ruby (setup-ruby with bundler-cache: true), Go (setup-go with cache: true), and Java (setup-java with cache: 'gradle' or 'maven').

One caveat: these actions cache the package manager download cache, not the installed output. For Node, that means ~/.npm is cached, not node_modules. The npm ci step still runs, but it skips downloading tarballs and installs from the local cache instead. This typically saves 30–60% of install time. If you need to skip the install entirely, cache node_modules directly with actions/cache, but make sure to include the Node version in the key to avoid cross-version breakage.

Fix 5

Warm the default branch cache for all PRs

GitHub Actions cache has strict branch scoping rules. A workflow run can only restore caches from its own branch or the default branch (usually main). PRs can also access the base branch cache. But caches created during a PR workflow are scoped to refs/pull/.../merge and can't be shared with other PRs or the base branch.

This means the first run of every new PR starts cold unless main has a matching cache. The fix is a lightweight workflow that runs on pushes to main and creates a warm cache entry that every PR can restore from.

.github/workflows/cache-warmup.yml
name: Warm Cache

on:
  push:
    branches: [main]
    paths:
      - 'package-lock.json'  # Only run when deps change

jobs:
  warm:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 20

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

      - run: npm ci

The paths filter ensures this workflow only runs when the lockfile actually changes on main, not on every merge. When it does run, it creates a cache entry on the default branch. Every subsequent PR that uses the same cache key pattern will get an immediate hit, because PR workflows can always read from the default branch cache.

One caveat: caches are immutable in GitHub Actions. If the key already exists, the save step is silently skipped. This is fine for lockfile-based keys, since the cache only needs to be created once per lockfile version. But if you use time-based or run-based keys, you'll create many unique entries and fill your 10 GB quota faster.


Reference

Cache key design checklist

A well-designed cache key follows the pattern ${{ runner.os }}-purpose-version-${{ hashFiles('lockfile') }}. Use this table to audit your cache keys against the most common mistakes:

Mistake Problem Fix
github.sha in key Unique key every push hashFiles('lockfile')
github.run_id in key Unique key every run hashFiles('lockfile')
Deps + build in one cache Invalidates on every commit Separate cache entries
Caching node_modules Breaks on Node version change Cache ~/.npm or use setup-node
restore-keys: OS only Matches wrong cache type Include purpose prefix
No matrix var in key Cross-version cache restore Add matrix.node-version
hashFiles('**/*.json') Matches nested lockfiles hashFiles('package-lock.json')

The purpose segment (e.g., deps, build) prevents cross-cache collisions. The version segment prevents cross-tool collisions. The lockfile hash ensures the cache only invalidates when its contents would actually change. Always include ${{ runner.os }} in your cache key because caches from Linux runners can't be restored on macOS or Windows.

Reference

Correct cache keys by ecosystem

Each language ecosystem has a different lockfile, cache path, and package manager. Here are the recommended key patterns and the setup action shorthand for the most common ones:

Ecosystem hashFiles() target Setup action
npm package-lock.json setup-node cache: 'npm'
yarn yarn.lock setup-node cache: 'yarn'
pnpm pnpm-lock.yaml setup-node cache: 'pnpm'
pip requirements.txt setup-python cache: 'pip'
bundler Gemfile.lock setup-ruby bundler-cache: true
Go go.sum setup-go cache: true
Gradle *.gradle*, gradle-wrapper.properties setup-java cache: 'gradle'
Cargo Cargo.lock None

Reference

GitHub Actions cache limits

Understanding the eviction rules helps explain why caches disappear unexpectedly and why thrashing is so expensive. Every unnecessary cache entry pushes important caches closer to eviction:

Limit Value Impact
Size per repo 10 GB default SHA-based keys fill this in days
Unused eviction 7 days Entries not accessed in 7 days are deleted
Over-limit eviction LRU Thrashing PR caches can evict main branch caches
Immutability Once created Can't update a bad entry, must wait for eviction

The shared 10 GB pool covers Actions caches, artifacts, and GitHub Packages storage. If you suspect thrashing has filled your cache, list entries via the gh CLI: gh actions-cache list -R owner/repo shows all entries with sizes and last access times. Delete stale ones with gh actions-cache delete <key> -R owner/repo. On repositories with many open PRs, consider a scheduled workflow that prunes cache entries from merged branches. For strategies to keep cache size in check, see our guide on reducing cache bloat.

FAQ

Frequently asked questions about GitHub Actions caching

What causes GitHub Actions cache misses even when using actions/cache?

The most common cause is including github.sha or github.run_id in the cache key, which creates a unique key on every run. The cache is saved but never restored by exact match. Use hashFiles('package-lock.json') instead so the key only changes when dependencies actually change.

Can a GitHub Actions cache make CI slower?

Yes. A thrashing cache adds download and upload overhead on every run while still requiring a full dependency install. In a typical scenario, a thrashing cache costs $74/month compared to $63/month with no cache at all — the cache adds $11/month in extra I/O overhead without saving any install time.

Should I cache node_modules or ~/.npm?

Cache ~/.npm (the download cache) rather than node_modules. The node_modules directory is tied to the Node.js version and OS, so restoring it across version changes causes silent build failures. Caching ~/.npm saves 30–60% of install time while remaining safe across Node version upgrades. Alternatively, use the built-in cache option in actions/setup-node@v4.

What is the GitHub Actions cache size limit?

Each repository gets 10 GB of total cache storage. There is no per-entry size limit. Cache entries not accessed within 7 days are automatically evicted. When the 10 GB limit is exceeded, GitHub removes the least recently used entries first, which means thrashing PR caches can evict important main branch caches.

What is the best cache key pattern for GitHub Actions?

Use the pattern ${{ runner.os }}-purpose-${{ hashFiles('lockfile') }}. The purpose segment (e.g., deps, build) prevents cross-cache collisions. The lockfile hash ensures the cache only invalidates when its contents would actually change. For matrix builds, include the matrix variable (e.g., matrix.node-version) to prevent cross-version restores.

Why do new pull requests always start with a cold cache?

GitHub Actions cache has strict branch scoping. PR workflows can only restore caches from their own branch or the default branch. If main doesn't have a matching cache entry, every new PR starts cold. Fix this with a cache-warmup workflow that runs on pushes to main when the lockfile changes.

Should I use separate caches for dependencies and build artifacts?

Yes. Dependencies change rarely (when the lockfile changes) while build artifacts change on every commit. A single cache entry bundling both invalidates on every push, causing thrash even with a good cache key. Split them into separate entries, each keyed on what actually causes that specific cache to become stale.

Related guides

Guides / Fix your GitHub Actions cache

See which workflows waste time on cache misses

CostOps tracks dependency install time, cache hit rates, and per-run cost so you can see exactly where caching is broken or thrashing.

Free for 1 repo. No credit card. No code access.

Built by engineers who've managed CI spend at scale.