Caching in CI is a double-edged sword: it speeds up builds dramatically, but if it’s wrong, it silently corrupts your tests.

Let’s watch a real CI job using GitHub Actions to build a Node.js project, specifically focusing on caching the node_modules directory.

name: CI with Caching

on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v3
    - name: Cache node modules
      uses: actions/cache@v3
      id: cache-nodemodules
      with:
        path: '**/node_modules'

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

        restore-keys: |

          ${{ runner.os }}-node-

    - name: Install dependencies
      if: steps.cache-nodemodules.outputs.cache-hit != 'true'
      run: npm ci
    - name: Run tests
      run: npm test

Here’s what’s happening:

  1. actions/checkout@v3 pulls your code.
  2. actions/cache@v3 is the star. It looks for a cache entry matching the key.
    • The key is constructed from the OS (runner.os) and a hash of your package-lock.json (hashFiles('**/package-lock.json')). This means if your dependencies change, the key changes, and a new cache is created.
    • restore-keys provides fallbacks. If an exact key match isn’t found, it tries to find a cache with a prefix like ubuntu-node-. This is crucial for when package-lock.json hasn’t changed but the runner.os might have (though less common on a single runner type).
  3. The Install dependencies step only runs npm ci if the cache wasn’t hit (steps.cache-nodemodules.outputs.cache-hit != 'true'). If the cache was hit, npm ci is skipped, and node_modules is restored from the cache.
  4. npm test then runs against the node_modules that are either freshly installed or restored from cache.

The "cache hit" output from the actions/cache step is your primary indicator. It will say Cache restored from key ... for a hit, or Cache not found for key ... for a miss.

The mental model is simple:

  • Cache Hit: The node_modules directory is exactly as it was when the cache entry was created. This is the fast path. npm ci is skipped.
  • Cache Miss: The node_modules directory is either empty or doesn’t match the key. npm ci runs, installs everything, and then this new state is uploaded as a cache entry for future runs.
  • Cache Expiry: Caches aren’t infinite. GitHub Actions has a retention policy (typically 7 days for workflow caches). After that, they are automatically deleted. Your key generation and restore-keys strategy are how you manage this.

This setup ensures that npm ci (which is generally faster and more deterministic than npm install) runs only when necessary. When it does run, it produces a clean, reproducible node_modules state that can then be cached.

The one thing most people don’t realize is how sensitive the key is. A change in any file that hashFiles is watching will invalidate the cache. This is usually package-lock.json or yarn.lock, which is good! But if you’re caching other things (like build artifacts), be incredibly precise with your hashFiles glob patterns. A too-broad pattern will cause unnecessary cache misses. For example, hashFiles('**/*') is almost always wrong for caching dependencies.

The next problem you’ll hit is cache invalidation leading to stale dependencies.

Want structured learning?

Take the full Caching-strategies course →