Guides / Caching too much

Artifacts & storage

Caching too much (and paying for it)

By Keith Mazanec, Founder, CostOps ยท Updated January 31, 2026

A team adds actions/cache to speed up CI. The cache grows to 4 GB. Every run now spends 72 seconds downloading and re-uploading that blob, which takes longer than a fresh npm ci would take. They're paying more for the cache than they save. This is more common than most teams realize, and the fix is straightforward: audit what you cache, split it by purpose, and prune what doesn't earn its keep. If your cache keys are also causing constant invalidation, see our guide on fixing cache thrash.

Symptoms

How to tell if your CI cache costs more than it saves

Open your workflow run logs and look at the Post Run actions/cache and Restore Cache step timings. Compare those to your install step. If the cache overhead is close to or exceeds install time, the cache is a net cost.

  • Cache restore takes longer than install. Your Restore Cache step logs show 40–90 seconds, but a clean npm ci or pip install finishes in 20–30 seconds. The cache is slower than starting from scratch.

  • Cache size is multi-gigabyte. Check with gh cache list --sort size --order desc. If entries are 2+ GB, the network transfer overhead on GitHub-hosted runners (roughly 50–200 MB/s effective throughput) becomes significant. A 4 GB cache takes ~30–40 seconds just to restore.

  • Repository cache storage is near or over 10 GB. GitHub provides 10 GB of cache storage per repository for free. Beyond that, you pay $0.07/GiB/month. Run gh cache list and add up total size. If you're close to the limit, LRU eviction kicks in hourly, which can evict useful caches and cause cascading misses on active branches.

  • Stale branch caches consuming quota. Feature branch caches persist after the PR merges. If your team merges 20 PRs/week and each creates a 500 MB cache, that's 10 GB of stale entries within a week, enough to fill the entire quota and trigger eviction of caches that active branches actually need.

Metrics

Quantify the waste

Cache overhead is billed as runner time. Every second spent restoring and saving a cache is a second on the clock at $0.006/min (Linux) or $0.062/min (macOS). Here's a typical scenario for a team running 80 workflows/day on Linux with a 4 GB cache:

Before optimization

Runs/day 80
Cache overhead/run 72s (2 min billed)
Monthly cache minutes 4,400
Monthly cache cost $26/mo

At $0.006/min (Linux 2-core)

After optimization (split + prune)

Runs/day 80
Cache overhead/run 15s (1 min billed)
Monthly cache minutes 2,200
Monthly cache cost $13/mo

Save $13/mo · $156/year · per workflow

That's one workflow on Linux. On macOS runners at $0.062/min, the same 4 GB cache overhead costs $272/mo before optimization and $136/mo after, saving $136/mo from cache hygiene alone. And this only counts runner time; if your total cache storage exceeds 10 GB, add $0.07/GiB/month in storage fees.


Fix 1

Audit your cache ROI with gh cache list

Before changing any YAML, measure what you have. The gh CLI shows every cache entry, its size, branch, and last access time. Sort by size to find the biggest offenders.

terminal
# List caches sorted by size (largest first)
gh cache list --sort size --order desc

# Sample output:
ID      KEY                                     SIZE     BRANCH          LAST USED
1234    Linux-node-abc123...                    3.8 GB   refs/heads/main  2 hours ago
1235    Linux-node-def456...                    3.7 GB   refs/heads/feat  4 days ago
1236    Linux-build-outputs-ghi789...           1.2 GB   refs/heads/main  1 hour ago

# Check total cache usage for the repo
gh cache list --json sizeInBytes -q '[.[].sizeInBytes] | add'

The decision rule is simple: for each cache entry, compare its restore + save time (in your workflow logs) against the time a cold install would take. If the cache overhead is within 50% of install time, it's borderline. If it exceeds install time, delete it and let the job install fresh.

Cache size ~Restore time ~Save time Total overhead
500 MB 5s 5s ~10s
1 GB 10s 10s ~20s
2 GB 20s 20s ~40s
4 GB 35s 35s ~70s

These timings are approximate for GitHub-hosted runners with ~100 MB/s effective throughput. Self-hosted runners transferring over the public internet will be significantly slower.

Fix 2

Split caches by purpose

A common pattern is caching everything in one blob, where dependencies, build outputs, and generated files all live under a single key. This means every job downloads the entire cache even when it only needs a fraction. Splitting caches by purpose lets each job restore only what it uses.

Dependencies and build outputs change at different rates. Dependencies change when you update package-lock.json. Build outputs change on every commit. Caching them together means the key invalidates on every commit, defeating the purpose of caching dependencies.

One giant cache
- uses: actions/cache@v4
  with:
    path: |
      node_modules
      .next/cache
      ~/.npm
    key: ${{ runner.os }}-all-${{ hashFiles('**') }}
    # Invalidates on every commit
    # Every job downloads 3+ GB
Split by purpose
# Deps cache: changes weekly
- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}

# Build cache: changes per commit
- uses: actions/cache@v4
  with:
    path: .next/cache
    key: ${{ runner.os }}-next-${{ hashFiles('src/**') }}

One caveat: splitting caches increases the number of cache entries, which counts against the 10 GB repository limit. The tradeoff is worth it: two 400 MB caches that hit 95% of the time are far more valuable than one 3 GB cache that misses on every commit.

Fix 3

Cache the download cache, not node_modules

This is the single most common caching mistake in Node.js projects. Teams cache node_modules/ directly, but then use npm ci as their install command. The problem: npm ci deletes node_modules before installing. Your 1.5 GB cached node_modules/ is downloaded, decompressed, and then immediately deleted.

The correct approach is to cache the npm download cache at ~/.npm. This is typically 200–400 MB instead of 1–2 GB, and npm ci uses it as a local registry to avoid network fetches. Even simpler: use actions/setup-node with the built-in cache option, which handles this automatically.

Wasted: cache deleted by npm ci
- uses: actions/cache@v4
  with:
    path: node_modules
    key: ${{ runner.os }}-nm-${{ hashFiles('**/package-lock.json') }}

- run: npm ci
  # npm ci deletes node_modules first
  # 1.5 GB downloaded for nothing
Correct: built-in cache with setup-node
- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: 'npm'
    # Caches ~/.npm (~300 MB)
    # npm ci uses it as local registry

- run: npm ci

The same principle applies to other ecosystems: cache the download cache, not the installed output. For Python, cache ~/.cache/pip (or use actions/setup-python with cache: 'pip'). For Ruby, use ruby/setup-ruby with bundler-cache: true. For Go, actions/setup-go with cache: true handles ~/go/pkg/mod automatically.

Fix 4

Prune stale branch caches on PR close

GitHub's cache scoping rules mean each feature branch creates its own cache entries. These persist for 7 days after last access, but on active repositories that merge frequently, dozens of stale branch caches accumulate and consume the 10 GB quota. When the quota fills, GitHub evicts caches hourly using LRU, which can evict the main branch cache that all PR builds need.

Add a cleanup workflow that deletes branch caches when a PR is closed or merged. This keeps storage lean and prevents eviction of high-value caches.

.github/workflows/cache-cleanup.yml
name: Cleanup Branch Caches

on:
  pull_request:
    types: [closed]

jobs:
  cleanup:
    runs-on: ubuntu-latest
    permissions:
      actions: write
    steps:
      - name: Delete branch caches
        env:
          GH_TOKEN: ${{ github.token }}
          REPO: ${{ github.repository }}
          BRANCH: refs/heads/${{ github.head_ref }}
        run: |
          gh cache list --ref "$BRANCH" --repo "$REPO" --json id -q '.[].id' |
            xargs -I {} gh cache delete {} --repo "$REPO"

This workflow runs on the free ubuntu-latest runner and typically completes in under 10 seconds. The actions: write permission is required to delete caches via the API. For repositories with many PRs, this single workflow can reclaim gigabytes of cache storage weekly.


Reference

What to cache by ecosystem

Each language has a “right” cache target. Caching the wrong directory is the most common source of cache bloat. Here's what to cache and what to avoid for the most common ecosystems:

Ecosystem Cache this Not this Typical size
Node.js ~/.npm node_modules/ 200–400 MB
Python ~/.cache/pip .venv/ 100–300 MB
Ruby vendor/bundle - 200–500 MB
Go ~/go/pkg/mod - 100–500 MB
Rust Swatinem/rust-cache target/ 500 MB–2 GB
Java/Gradle gradle/actions/setup-gradle ~/.gradle/caches/ 300 MB–1 GB

For Rust and Java/Gradle, use the dedicated cache actions rather than raw actions/cache. These tools handle pruning, layered caching, and size management automatically. The Rust target/ directory grows without bound and can easily reach 5–10 GB; Swatinem/rust-cache sets CARGO_INCREMENTAL=0 and removes artifacts older than one week to keep cache size in check.

Reference

GitHub Actions cache limits and pricing

These are the current limits as of January 2026. Cache storage is shared with Actions artifacts and GitHub Packages within the same repository.

Limit Value
Free cache storage per repo 10 GB
Overage pricing $0.07/GiB/month
Unused cache retention 7 days
Eviction policy LRU, hourly
Max cache key length 512 characters
Upload rate limit 200 entries/min/repo

Eviction frequency was increased from daily to hourly in September 2025. This means cache pressure is felt faster. If your repository is near the 10 GB limit, a burst of PR activity can evict main-branch caches within the hour. Keep total cache usage well under the limit to avoid cascading misses.

Related guides

Guides / Caching too much

See which caches cost more than they save

CostOps tracks cache step duration, cache size, and restore/save overhead per workflow. Find the caches that are slowing you down.

Free for 1 repo. No credit card. No code access.

Built by engineers who've managed CI spend at scale.