Skip to main content

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 cache
  • key: A unique identifier for this cache. It includes the OS and a hash of package-lock.json. If dependencies change, the hash changes and a new cache entry is created
  • restore-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:

  1. ${{ runner.os }}: The runner OS (ubuntu-latest, windows-latest, macos-latest). Different OSes have different binaries, so cache keys are OS-specific
  2. ${{ hashFiles(...) }}: A SHA256 hash of package-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

ScenarioWithout CacheWith CacheSavings
First run on new runner45s45s0%
Repeat run, no changes45s3s93%
Dependency update50s50s0%
Small code change45s3s93%

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_modules using actions/cache reduces 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-keys to 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:

  1. Lock file changed: run npm ci locally and commit the updated lock file
  2. Different Node version: ensure node-version is consistent
  3. 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.

Further Reading