Caching stores expensive-to-produce files (downloaded dependencies, compiled outputs, browser binaries) between pipeline runs, keyed by a fingerprint of their inputs, so subsequent runs restore them instead of regenerating them from scratch.
Because runners are ephemeral, every job would otherwise re-download all dependencies and browsers on each run — often the slowest part of the pipeline. A cache is keyed by a hash of the relevant lockfile (e.g. package-lock.json): if the key matches a previous run, the cache is restored; if dependencies changed, the key changes and a fresh cache is built and saved. The critical rule is correct keying — too broad and you serve stale dependencies; too narrow and you never get a hit. Restore-keys provide graceful fallback to a partial cache. Caching is distinct from artifacts: caches are an optimization that can be regenerated and shouldn't be relied upon for correctness, whereas artifacts are outputs you deliberately persist. Playwright browsers are a common high-value cache target since they're large.
A job spent 90 seconds every run reinstalling dependencies and 60 seconds downloading Playwright browsers. Adding a cache keyed on package-lock.json for both cut cold steps to a few seconds on cache hits, shaving over two minutes off every PR run.
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm' # caches the npm download cache, keyed on lockfile
- run: npm ci
# Cache Playwright browser binaries, keyed on the installed version
- name: Cache Playwright browsers
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: pw-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
restore-keys: pw-${{ runner.os }}-
- run: npx playwright install --with-depsScenario: After adding a dependency cache, a developer bumps a library version but CI keeps using the OLD version and tests pass against code that no longer exists. How did the cache cause this and how do you key it correctly?