Performance

Cache Invalidation in CI/CD: The Silent Pipeline Killer

Stale CI/CD caches cause failures that look like completely unrelated bugs. Learn proper cache key hashing, versioning strategies, and when caching does more harm than good.

D

Daxtack Team

Engineering

10 min read

Caching in CI/CD is a deal with the devil. You get faster builds — sometimes dramatically faster. In return, you get an entire class of failures that are nearly impossible to diagnose because the error message and the root cause are in completely different parts of the log.

A stale cache doesn't announce itself. It silently serves outdated dependencies, old build artifacts, or corrupted state. Your pipeline fails with an error that looks like a code bug, and you spend 45 minutes debugging your code before realizing the code was never the problem.

Why Caching Is a Double-Edged Sword

CI caches exist to avoid repeating expensive operations — installing dependencies, compiling binaries, downloading large assets. When they work, they can cut build times by 50-80%. When they don't, they create some of the hardest-to-diagnose failures in CI/CD.

The fundamental tension: caches are useful because they persist state across runs. But persisted state is exactly what makes CI non-deterministic. Every time you add a cache, you're trading reliability for speed.

The Three Cache Failure Modes

1. Stale Dependencies

The most common cache failure. Your package-lock.json or go.sum changed, but your cache key doesn't account for it. The cache restores the old node_modules, and your build uses outdated (or incompatible) packages.

Typical symptoms:

  • Import errors for packages that definitely exist in your lockfile
  • Type errors from mismatched package versions
  • "Module not found" for a recently added dependency
  • Tests pass locally but fail in CI (because local has the right deps)

Root cause: The cache key doesn't hash the lockfile:

# Bad: cache key doesn't change when dependencies change
- uses: actions/cache@v4
  with:
    path: node_modules
    key: deps-${{ runner.os }}           # ← No lockfile hash!

# Good: cache key includes lockfile hash
- uses: actions/cache@v4
  with:
    path: node_modules
    key: deps-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

2. Corrupted Cache Entries

Less common but more insidious. A cache entry was saved from a partially-completed step (job was cancelled mid-install), or the cache format is incompatible with a new tool version.

Typical symptoms:

  • Segfaults or internal errors in otherwise stable tools
  • EINTEGRITY errors from npm
  • "Invalid cache entry" or checksum mismatch warnings
  • Build works on first run (no cache), fails on second run (cache restored)

Fix: Purge the cache and rebuild:

# GitHub Actions: delete caches via API or CLI
gh cache delete --all

# Or bump the cache version in your key
key: v2-deps-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
#    ^^  version bump forces new cache

3. Cross-Branch Cache Contamination

Branch A saves a cache. Branch B restores it. But Branch B has different dependencies, a different configuration, or a different project structure. The stale cache from Branch A causes Branch B to fail.

GitHub Actions caches are scoped to branches by default — but there's a fallback mechanism:

restore-keys: |
  deps-${{ runner.os }}-

This restore-keys fallback will match a cache from any branch with the same prefix. That's usually fine, but can cause issues when branches have diverged significantly.

The Debugging Trap

Here's why cache issues waste so much time: the failure looks like something completely different.

Example scenario:

  1. You add a new dependency: npm install zod
  2. Your lockfile updates, but your cache key only hashes package.json (not package-lock.json)
  3. CI restores the old node_modules (no zod)
  4. Your code runs: import { z } from 'zod'
  5. Error: Cannot find module 'zod'

You look at the error. You check your package.jsonzod is there. You check your import — it's correct. You run npm install locally — everything works. You spend 30 minutes confused before someone suggests "try clearing the cache."

This is the textbook case for why the error message and the root cause are often in completely different places in the log. It's the exact problem Daxtack's log intelligence is built around — tracing a downstream failure back to the actual upstream cause instead of you guessing based on where the red text shows up.

Proper Cache Key Strategies

Hash Everything That Matters

# Good: hashes the lockfile
key: deps-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}

# Better: includes tool version too
key: deps-${{ runner.os }}-node${{ matrix.node }}-${{ hashFiles('**/package-lock.json') }}

# Best for monorepos: hash only the relevant lockfile
key: deps-${{ runner.os }}-${{ hashFiles('apps/web/package-lock.json') }}

Use Cache Versioning

Add a version prefix to your cache key. When you need to bust the cache (tool upgrade, corruption), bump the version:

key: v3-build-${{ runner.os }}-${{ hashFiles('**/*.go', 'go.sum') }}

Separate Build Cache from Dependency Cache

# Dependency cache (changes rarely)
- uses: actions/cache@v4
  with:
    path: node_modules
    key: deps-${{ hashFiles('package-lock.json') }}

# Build cache (changes every commit)
- uses: actions/cache@v4
  with:
    path: .next/cache
    key: build-${{ github.sha }}
    restore-keys: build-    # Always restore latest build cache

When to Just Not Cache

Sometimes the answer is: don't cache. Remove the cache if:

  • Your builds are already fast — if npm ci takes 20 seconds, caching saves ~15 seconds but adds debugging complexity
  • You're debugging a flaky pipeline — remove all caches first to establish a clean baseline
  • Security is paramount — cached dependencies aren't verified against the lockfile on restore. A supply chain attack could persist in your cache
  • You're using npm cinpm ci deletes node_modules before installing, so caching node_modules is pointless. Cache ~/.npm instead:
# Cache the npm download cache, not node_modules
- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ hashFiles('package-lock.json') }}

GitLab CI Cache Specifics

GitLab CI caches work differently from GitHub Actions. Key differences:

build:
  cache:
    key:
      files:
        - package-lock.json    # Auto-hashes these files
      prefix: $CI_COMMIT_REF_SLUG  # Branch-scoped
    paths:
      - node_modules/
    policy: pull-push    # pull = restore, push = save. Use pull for read-only jobs
  script:
    - npm ci
    - npm run build
  • policy: pull — only restores cache, never saves. Use for test/deploy jobs that shouldn't update the cache
  • policy: push — only saves, never restores. Use for cache-warming jobs
  • when: on_success — only cache on successful jobs (default). Prevents caching broken state

The Cache Debugging Checklist

  1. ☐ Reproduce the failure with all caches cleared
  2. ☐ If it passes without cache — your cache is the problem
  3. ☐ Verify your cache key hashes all relevant files (lockfile, config, tool versions)
  4. ☐ Check if a recent tool or runtime upgrade invalidated the cache format
  5. ☐ Look for restore-keys that might be restoring a cache from the wrong branch
  6. ☐ Ensure you're caching the right path (~/.npm vs node_modules)
  7. ☐ Bump the cache version to force a clean rebuild
CI/CDCache InvalidationGitHub ActionsGitLab CIPerformancePipeline DebuggingDevOps

Debug CI/CD failures in 30 seconds

Daxtack uses AI to automatically analyze your build logs, find the root cause, and suggest fixes — right in your pull request.

Related Articles