Skip to content
ExponaBLOG
GIT & GITHUB FOR PROFESSIONAL DEVELOPERS · DAY 038 / 100

Caching in GitHub Actions: practical tips and trade-offs

Faster pipelines.

Expona Admin6 min read

The Pipeline Slowdown Problem

We've all watched a CI/CD pipeline crawl through dependency resolution, compilation, and test setup—sometimes taking 10–15 minutes for a workflow that should finish in 2. The culprit is rarely the code itself; it's the repeated download and installation of the same dependencies, node modules, or compiled artifacts on every run. Caching in GitHub Actions: practical tips and trade-offs is about reclaiming that lost time while staying aware of when caching helps versus when it creates fragile, hard-to-debug builds.

Setup-* Actions Have Built-in Caching

The popular setup actions—setup-node, setup-python, setup-dotnet, setup-java—come with caching baked in. You don't need to wire up actions/cache manually; just enable the feature with a single flag. This is the easiest win for most teams.

Let's say your workflow installs Node.js and builds a Next.js application. Without caching, npm install might take 45 seconds on every push. With it, that drops to a few seconds after the first run in a given week.

bash
name: Build and Test
on: [push, pull_request]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      
      - run: npm ci
      - run: npm run build
      - run: npm test

That cache: 'npm' line is the key. GitHub Actions will automatically hash your package-lock.json, restore cached node_modules if the hash matches, and upload any changes after the job completes. The same pattern works for yarn (use cache: 'yarn') and pnpm (use cache: 'pnpm').

For Python, enable it with cache: 'pip'. For .NET, use cache: 'nuget'. Each setup action knows what to cache and where to find it. The trade-off here is minimal: you get speed almost for free, and the only real risk is if your lock file becomes stale—but that's a workflow discipline issue, not a caching issue.

Actions/cache for Custom Paths

Sometimes you need to cache things outside the standard dependency folders. Maybe you're caching compiled Go binaries, build outputs, Docker layer caches, or intermediate artifacts from a multi-stage workflow. That's where actions/cache comes in.

Here's a real scenario: you build a CLI tool in Go, run tests, then package it. The binary build takes 90 seconds. If you cache the GOPATH or build cache, subsequent runs drop to 10 seconds.

bash
name: Build Go CLI
on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      
      - uses: actions/setup-go@v4
        with:
          go-version: '1.22'
      
      - uses: actions/cache@v4
        with:
          path: |
            ~/.cache/go-build
            ~/go/pkg/mod
          key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
          restore-keys: |
            ${{ runner.os }}-go-
      
      - run: go build -o cli ./cmd/cli
      - run: go test ./...
      - run: ./cli version

The path field specifies what to cache—usually build artifacts or dependency folders. The key is critical: it's a unique identifier for this cache entry. If the key matches a previous run, the cache is restored. If not, a new entry is created. The restore-keys fallback lets you use a partial match; if the exact key doesn't exist, GitHub looks for any key that starts with the restore-keys prefix.

The trade-off here is complexity and size. Each cache entry has a 5 GB per-repository limit across all branches. If you're not careful, you can fill that quota and start evicting useful caches. Also, stale cached artifacts can hide real problems—a cached binary might work locally but fail in production if a dependency was quietly broken.

Key Caches by Lock File Hash

The most reliable caching strategy ties your cache key to the content of your dependency lock file (or equivalent). This ensures that when dependencies change, the cache is automatically invalidated and rebuilt. It's the principle behind the built-in caching in setup actions, and it's equally important when using actions/cache manually.

In a Node.js project, use hashFiles('**/package-lock.json'). In Python, hashFiles('**/requirements.txt') or **/Pipfile.lock. In Go, hashFiles('**/go.sum'). The hash is computed from the file content, so any change to dependencies triggers a cache miss and a fresh install.

bash
- uses: actions/cache@v4
  with:
    path: |
      node_modules
      dist
    key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-node-

Why not just use a static key like my-cache-v1? Because then you have to manually update it whenever dependencies change, and you'll likely forget. Or worse, you'll get a stale cache that breaks the build silently. The hash-based approach is self-healing: it automatically detects changes without ceremony.

The downside is that if you're debugging a flaky build, understanding cache keys can be tricky. Always include the restore-keys fallback so that even if there's no exact match, the workflow doesn't fail—it just does a fresh install and caches the result for next time.

Measure Before and After

Caching feels good in theory, but you need data to justify the complexity. Before adding cache, note the average job duration. After enabling it, measure again over at least 5–10 runs to smooth out noise (network variance, runner load, etc.).

GitHub Actions provides timing data in the job logs. Look for the step duration, not wall-clock time (which includes queuing). Most teams see 30–60% improvement on the first cache hit and sustained improvement on subsequent runs.

Scenario Without Cache With Cache (First Hit) Speedup
Node.js (npm ci + build) 60s 15s 4x
Python (pip install + tests) 45s 8s 5.6x
Go (build cache) 90s 10s 9x
.NET (NuGet restore) 75s 12s 6.25x

These are typical numbers from production e-commerce and SaaS workflows. Your mileage varies based on dependency count, network speed, and runner specs. The key point: measure your own pipeline. If npm ci takes 5 seconds already, caching it won't move the needle. Focus on the slowest step.

Use GitHub Actions' built-in timing information in the workflow logs. Click on each step to see the duration. Then enable caching, run a few times, and compare. If you see consistent 2x+ improvement, it's worth keeping. If it's marginal (10–15% faster) but adds mental overhead, consider whether the tradeoff is worth it.

Common Mistakes

  • Caching without a proper key strategy. Using a static key like cache-v1 defeats the purpose. When dependencies update, you'll get a cache miss anyway, or worse, a stale cache. Always hash your lock file or use hashFiles().
  • Caching too much. A cache entry that's 2+ GB takes time to upload and download. Cache only what's slow to rebuild. Don't cache dist folders unless they're genuinely expensive to generate.
  • Ignoring cache misses on first PR runs. The first time a PR runs, there's no cache. The workflow will be slow. That's normal and expected. Don't panic or add unnecessary steps to work around it.
  • Trusting stale caches without validation. If you cache a built artifact (a binary, a compiled library), add a checksum or version step afterward to confirm it's not corrupted. A stale cache can mask real breakage.
  • Not cleaning up old cache entries. GitHub automatically evicts the least-recently-used entries when you hit the 5 GB limit, but it's worth monitoring. If you're cycling through cache keys rapidly (e.g., different lock file hashes on every commit), you might evict useful entries.

Key Takeaways

  • Use the built-in caching in setup-node, setup-python, and other setup actions first. It's the easiest win with minimal configuration.
  • For custom paths, use actions/cache with a key derived from your lock file hash (hashFiles()) to ensure automatic cache invalidation when dependencies change.
  • Always include a restore-keys fallback to handle cache misses gracefully without breaking the workflow.
  • Measure the before and after timings over multiple runs. A consistent 2x+ speedup justifies the added configuration; marginal gains may not be worth the complexity.
  • Be aware of the 5 GB per-repository cache limit and avoid caching large, frequently-changing artifacts. Cache dependencies and build outputs, not entire node_modules folders unless you really need to.
  • Include validation steps (checksums, version checks) when caching compiled artifacts to catch stale or corrupted cache entries early.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles