React Dependency Caching: Speed Up Your CI
GitHub Actions caching stores dependencies (node_modules/) between workflow runs, avoiding redundant downloads. Caching can reduce CI runtime by 60-70% because npm ci downloads all dependencies from npm registry on every run. Downloading a typical React app's dependencies takes 30-60 seconds; caching reduces that to 2-5 seconds by restoring from a local cache.
How GitHub Actions Caching Works
GitHub Actions stores and restores cache entries using actions/cache@v4. The cache is keyed by a hash of your dependency lock file (package-lock.json). If the lock file hasn't changed, the cache hit is automatic and dependencies are restored. If dependencies change, a new cache entry is created.
Each repository gets 5 GB of cache storage. Unused caches are deleted after 7 days, so active projects maintain current caches without storage concerns.
Adding Cache to Your Workflow
Use the actions/cache action to cache node_modules/:
name: React CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Cache dependencies
uses: actions/cache@v4
with:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Install dependencies
run: npm ci
- name: Build React app
run: npm run build
Breaking down the cache action:
path: node_modules: Specifies the directory to cachekey: A unique identifier for this cache. It includes the OS and a hash ofpackage-lock.json. If dependencies change, the hash changes and a new cache entry is createdrestore-keys: Fallback keys. If the exact key isn't found, GitHub tries${{ runner.os }}-npm-(matches any npm cache from this OS)
Understanding Cache Keys
The cache key determines whether a cache hit occurs:
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
This key includes:
${{ runner.os }}: The runner OS (ubuntu-latest, windows-latest, macos-latest). Different OSes have different binaries, so cache keys are OS-specific${{ hashFiles(...) }}: A SHA256 hash ofpackage-lock.json. If you upgrade a dependency, the lock file changes and the hash changes, creating a new cache
Example cache keys:
Linux-npm-a1b2c3d4e5f6g7h8i9j0... (exact match if lock file is identical)
Linux-npm- (fallback, matches any Linux npm cache)
When you push a commit with an unchanged package-lock.json, GitHub finds the exact cache key and restores in seconds. When you add a dependency and push an updated package-lock.json, the key changes, so GitHub downloads fresh dependencies and creates a new cache entry.
Caching Build Artifacts
You can also cache build outputs (e.g., dist/ or .next/) if your build is deterministic. This can save time on subsequent runs:
- name: Cache build artifacts
uses: actions/cache@v4
with:
path: dist
key: ${{ runner.os }}-build-${{ github.sha }}
restore-keys: |
${{ runner.os }}-build-
Use github.sha (the commit hash) as the key so each build gets a unique cache. On subsequent runs of the same commit, the build is restored instead of recompiled.
Performance Impact: Before and After
| Scenario | Without Cache | With Cache | Savings |
|---|---|---|---|
| First run on new runner | 45s | 45s | 0% |
| Repeat run, no changes | 45s | 3s | 93% |
| Dependency update | 50s | 50s | 0% |
| Small code change | 45s | 3s | 93% |
Caching has the biggest impact on workflows that run frequently without dependency changes. In a typical team, 80% of commits don't change package-lock.json, so caching provides significant time savings.
Cache Strategies for Monorepos
If your project uses npm workspaces or Yarn, adjust the cache path to include all lock files:
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
node_modules
packages/*/node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
Key Takeaways
- Caching
node_modulesusingactions/cachereduces CI runtime by 60-90% when dependencies haven't changed - The cache key must include a hash of your lock file to bust the cache when dependencies change
- Each cache entry is OS-specific; Windows, Linux, and macOS runners maintain separate caches
- Caching is automatic: once configured, cache hits happen transparently without code changes
- Use
restore-keysto enable fallback matches so cache misses don't fail the workflow
Frequently Asked Questions
Why isn't my cache being used?
Check GitHub Actions logs for Cache key does not match or Cache miss. Common causes:
- Lock file changed: run
npm cilocally and commit the updated lock file - Different Node version: ensure
node-versionis consistent - Different runner OS: cache keys are OS-specific
Can I manually clear the cache?
Yes, use the Actions tab on GitHub: click Caches in the workflow sidebar and delete the cache entry. The next run creates a fresh cache.
Should I cache package-lock.json?
No, never cache the lock file itself. Always commit it to git. Caching the hash of the lock file is correct; caching the file creates circular dependencies.
How much storage does caching use?
A typical React app's node_modules is 300-500 MB. GitHub gives 5 GB per repository, which stores 10-15 cache versions. Old caches are automatically pruned after 7 days of inactivity.