Skip to main content
GitHub Actions · lesson 7 of 7
GitHub Actions intermediate Lesson 7 of 7

actions/cache: Measuring What a Cache Hit Actually Skips

A real cache miss and hit against the same workflow under act — the exact step a hit skips entirely, and the fixed act overhead a hit doesn't remove.

By SkillByExample · Published

github-actions caching ci-cd act

actions/cache promises to skip expensive, repeatable work. Whether it actually does, and by how much, is measurable — run the same workflow twice under act and compare.

The workflow under test

name: cache-test
on: push
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Restore cache
        id: cache
        uses: actions/cache@v4
        with:
          path: slow-output
          key: slow-output-v3

      - name: Slow step
        if: steps.cache.outputs.cache-hit != 'true'
        run: |
          echo "cache miss - doing slow work"
          mkdir -p slow-output
          sleep 12
          echo "done" > slow-output/result.txt

      - name: Report
        run: |
          echo "cache-hit=${{ steps.cache.outputs.cache-hit }}"
          cat slow-output/result.txt

sleep 12 stands in for whatever the real slow step would be — a dependency install, a compile, a large download. The if on “Slow step” is what actually saves time; actions/cache by itself only restores files and reports whether it found them.

First run: a real miss

$ act push -P ubuntu-latest=catthehacker/ubuntu:act-latest

⭐ Run Main Restore cache
  | Cache not found for input keys: slow-output-v3
  ✅ Success - Main Restore cache [4.5543982s]
⭐ Run Main Slow step
  | cache miss - doing slow work
  ✅ Success - Main Slow step [12.243717s]
⭐ Run Main Report
  | cache-hit=
  | done
⭐ Run Post Restore cache
  | Cache saved successfully
  | Cache saved with key: slow-output-v3
  ✅ Success - Post Restore cache [306.2339ms]

No cache existed for slow-output-v3, so “Slow step” ran for real — 12.24 seconds, matching the sleep 12. cache-hit came back empty (falsy), which is what let the if condition let “Slow step” run at all. At the very end, “Post Restore cache” saved the output for next time.

Second run, same key: a real hit

$ act push -P ubuntu-latest=catthehacker/ubuntu:act-latest

⭐ Run Main Restore cache
  | Cache restored successfully
  | Cache restored from key: slow-output-v3
  ✅ Success - Main Restore cache [4.7529941s]
⭐ Run Main Report
  | cache-hit=true
  | done
⭐ Run Post Restore cache
  | Cache hit occurred on the primary key slow-output-v3, not saving cache.
  ✅ Success - Post Restore cache [232.3128ms]

“Slow step” doesn’t even appear in the log — its if condition evaluated false and it was skipped entirely, not run-and-discarded. That’s the real 12.24 seconds this run didn’t spend. cache-hit=true in “Report” confirms result.txt came from the restored cache, not a fresh run.

The number that isn’t a real saving

“Restore cache” itself took 4.55s on the miss and 4.75s on the hit — essentially the same, despite one finding nothing and the other extracting a real cache archive. That’s because most of this step’s time here is act’s own fixed cost: copying actions/cache’s bundled JavaScript into the container and starting a node process, on every single run regardless of outcome. This overhead is specific to running under act locally, not something a real GitHub-hosted runner pays at the same scale — so the honest comparison this lesson can make is “12.24 seconds of work, skipped entirely,” not “the whole job got roughly 4 seconds faster,” because the step actions/cache itself runs in didn’t get faster at all.

Practice

1. Run the workflow a third time with the same slow-output-v3 key. Does "Restore cache" report a hit again, and does its timing change further?
⭐ Run Main Restore cache
  | Cache restored successfully
  | Cache restored from key: slow-output-v3
  ✅ Success - Main Restore cache [4.9418108s]
⭐ Run Main Report
  | cache-hit=true
  | done

Same hit, same skipped “Slow step”, and “Restore cache” stays in the same 4.5-4.9s band as both earlier runs — confirming that band is fixed per-run overhead, not something that trends down as the cache “warms up.” There’s no such thing as a warmer cache here; it’s either a hit or it isn’t.

2. Change the path in the cache step to a directory the "Slow step" doesn't actually write to (e.g. wrong-dir instead of slow-output), keep the same key, and run again. What happens?
⭐ Run Main Restore cache
  | Cache not found for input keys: slow-output-v3
  ✅ Success - Main Restore cache [4.5212475s]
⭐ Run Main Slow step
  | cache miss - doing slow work
  ✅ Success - Main Slow step [12.2197555s]
⭐ Run Main Report
  | cache-hit=
  | done
⭐ Run Post Restore cache
  | [warning]Path Validation Error: Path(s) specified in the action for caching do(es) not
  |   exist, hence no cache is being saved.
  ✅ Success - Post Restore cache [224.7726ms]

Full miss, full 12-second “Slow step” again — a changed path invalidates the previously-saved cache for that key, since actions/cache keys its lookup on the exact (key, path) pair, not key alone. But it doesn’t stop there: “Slow step” still writes to slow-output (unchanged), so wrong-dir never gets created — the save at the end genuinely can’t find anything at wrong-dir to archive, and logs a real path-validation warning instead of silently saving an empty cache. A mismatched path doesn’t just cost you a hit once; every subsequent run stays a guaranteed miss until path and the step that actually populates it agree.

3. Add a second, unrelated fast step before "Slow step" that always runs (no if). Does it affect whether the cache hit still skips "Slow step" correctly?
⭐ Run Main Always runs
  | this always executes
  ✅ Success - Main Always runs [267.154ms]
⭐ Run Main Restore cache
  | Cache restored successfully
  | Cache restored from key: slow-output-v3
  ✅ Success - Main Restore cache [6.5093383s]
⭐ Run Main Report
  | cache-hit=true
  | done

No — steps.cache.outputs.cache-hit is scoped to the cache step’s own id, not affected by unrelated steps elsewhere in the job. “Slow step” still doesn’t appear in the log at all. (The 6.5s “Restore cache” timing here runs a bit above the 4.5-4.9s band the earlier runs established — plausible container/host variance between individual act invocations, not a change in what the step is doing.) The if condition only ever depends on the specific step output it names.

Frequently Asked Questions

Does actions/cache genuinely work under act, or is it simulated?
It genuinely works — act runs the real actions/cache source (cloned from GitHub at ref=v4) against a local disk cache under ~/.cache/act/actions-cache@v4/, not GitHub's real hosted cache service. The restore/save logic, the cache-hit output, and the key-matching behavior are all real; only the storage backend is local instead of GitHub's.
Why doesn't the 'Restore cache' step get much faster on a cache hit?
Because most of its time here is act's own fixed overhead — copying the cache action's bundled dist/ into the container and starting a node process — not the actual cache lookup. This lesson measured 4.55s on a miss and 4.75s on a hit for that step alone. That overhead is act-specific and not representative of real GitHub-hosted runner timing; the step genuinely worth trusting the number on is the one the cache-hit condition skips entirely.
What determines whether a step gets skipped on a cache hit?
Nothing automatic — actions/cache only restores files and sets a cache-hit output; you still have to write an if: steps.<id>.outputs.cache-hit != 'true' condition on whatever step the cache is meant to replace, the same way this lesson's 'Slow step' is skipped.
Does changing only the cache key force a fresh miss, or does the cached content matter too?
The key and the path together determine whether restore finds a match — actions/cache does exact matching on the (key, path) pair (or prefix matching on the key via restore-keys), not content hashing. This lesson's Practice section shows that changing just the path with the key unchanged is enough to force a fresh miss, even though the cached content itself never changed.