GitHub Actions Tutorial — Cache Strategies

2 minute read

Cache Strategies

A good cache key strategy maximizes cache hit rates while ensuring stale caches don’t cause issues. This page covers patterns for designing effective cache keys and understanding cache behavior.


Cache Key Design

Exact Match Key


key: node-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

This key changes only when dependencies change. Perfect for dependency caches.

Restore Keys (Fallback)

When the exact key misses, restore-keys tries prefix matches:


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

Match order:

  1. Exact match on key → perfect hit
  2. Prefix match on node-Linux- → partial hit (most recent)
  3. Prefix match on node- → broader fallback

A partial hit restores an older cache. Your install command updates the difference.


Branch-Based Caching

Cache behavior across branches:

main branch:
  ├── Cache: node-Linux-abc123 (from main)
  │
feature branch:
  ├── Can READ main's cache (restore-keys fallback)
  ├── Creates own cache: node-Linux-def456
  │
main branch:
  └── Cannot read feature branch's cache

Rules:

  • Feature branches can read the default branch’s cache
  • Feature branches can read their own cache
  • The default branch can only read its own cache
  • Pull requests follow the feature branch rules

Cache Busting

Sometimes you need to force a fresh cache:

Method 1 — Change Prefix


# Before
key: v1-node-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

# After (busted)
key: v2-node-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

Method 2 — Include Date


key: node-${{ runner.os }}-week${{ github.run_number }}-${{ hashFiles('package-lock.json') }}


Cache Eviction

GitHub evicts caches when:

Condition Behavior
Not accessed for 7 days Auto-deleted
Repository cache > 10 GB Oldest caches evicted first
Manual deletion Via GitHub API or CLI

View and Manage Caches

# List caches
gh cache list

# Delete a specific cache
gh cache delete <cache-id>

# Delete all caches
gh cache delete --all

Multi-Key Strategy

For projects with multiple dependency types:


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

  - name: Cache pip
    uses: actions/cache@v4
    with:
      path: ~/.cache/pip
      key: pip-${{ runner.os }}-${{ hashFiles('requirements.txt') }}
      restore-keys: pip-${{ runner.os }}-

  - name: Cache build output
    uses: actions/cache@v4
    with:
      path: .next/cache
      key: nextjs-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-${{ hashFiles('src/**') }}
      restore-keys: |
        nextjs-${{ runner.os }}-${{ hashFiles('package-lock.json') }}-
        nextjs-${{ runner.os }}-


Cache vs Install Timing

Scenario npm ci Time With Cache
No cache ~60s ~60s
Cache hit (npm global) ~60s ~20s
Cache hit (node_modules) ~60s ~5s

Caching node_modules directly is faster but riskier (can become stale). Caching ~/.npm is safer — npm ci still runs but skips downloads.


Common Mistakes

Mistake Fix
Cache never hits on PRs PRs can read the default branch cache via restore-keys
Cache is stale Include lock file hash in key; stale deps get replaced
Cache too large (>10 GB) Cache only what’s needed; exclude build artifacts
Same key across OS Include runner.os in the key

Quick Reference

Strategy Key Pattern
Standard prefix-${{ runner.os }}-${{ hashFiles('lockfile') }}
With version v2-prefix-${{ runner.os }}-${{ hashFiles('lockfile') }}
Build cache build-${{ runner.os }}-${{ hashFiles('lockfile') }}-${{ hashFiles('src/**') }}
Restore fallback Most specific → least specific prefixes
Cache expiry 7 days since last access
Max per repo 10 GB total

← Previous: Dependency Caching

Next: Monorepo Caching →

Updated: