Setting Up GitHub Actions Caching for Faster CI
Performance-gating pipelines routinely blow past an acceptable PR check window because every job reinstalls dependencies and re-downloads browser binaries from scratch. When you fan an audit across several device and network profiles — the pattern in GitHub Actions Performance Matrices — that redundant work multiplies by the number of variants, and a 4-minute install becomes the dominant cost. The fix is targeted artifact caching: cache the static, slow-to-rebuild layers (npm download cache, Chrome/Playwright binaries, framework build cache) and leave the dynamic output that carries your budget signal uncached. The target here is a PR check under 8 minutes with a cache hit rate above 85%.
Cache-Hit / Time Breakdown
The table shows where the time goes on a representative mid-size frontend repo and what each cache layer recovers. The npm download cache and the browser-binary cache deliver most of the win; the build cache helps incremental rebuilds but must never include directories that carry performance budgets or compiled assets.
| Cache layer | Cold (miss) | Warm (hit) | Recovered | Invalidates on |
|---|---|---|---|---|
npm download (~/.npm) |
95 s | 12 s | ~83 s | package-lock.json |
| Chrome / Playwright binaries | 70 s | 4 s | ~66 s | lockfile (binary version) |
Framework build (.next/cache) |
60 s | 18 s | ~42 s | lockfile |
| Full PR check (matrix) | 14.2 min | 6.8 min | ~52% | any key change |
Diagnostic Steps
First, confirm whether a cache is even being hit. The cache-hit output of actions/cache is the ground truth; a perpetually false value means your key is unstable.
# In a workflow step, surface the hit/miss for the restore
echo "npm cache hit: ${{ steps.npm-cache.outputs.cache-hit }}"
Second, check that your key is deterministic across identical commits. A key that embeds a branch name or timestamp can never hit on a fresh PR.
# Reproduce the hash the workflow computes, locally
sha256sum package-lock.json lighthouserc.json | awk '{print $1}'
# A stable, identical hash across commits = a cacheable key
Third, measure the actual wall-clock saved by reading job durations from the Actions API rather than eyeballing the UI.
gh run list --workflow perf-matrix.yml --limit 10 \
--json databaseId,conclusion,createdAt,updatedAt \
| jq -r '.[] | "\(.conclusion)\t\(.databaseId)"'
Implementation
Use composite hashFiles() keys that invalidate when the lockfile, the Lighthouse config, or the WebPageTest script changes — and provide restore-keys for partial fallback so a lockfile bump still recovers the unchanged browser binaries. Cache only static directories: ~/.npm, the browser binary store, and node_modules/.cache. Never cache .next output or dist, which can mask a regression by serving stale, already-optimized assets.
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Cache npm download store
id: npm-cache
uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
- name: Cache browser binaries
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: browsers-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
- name: Cache Lighthouse + framework build cache
uses: actions/cache@v4
with:
path: |
node_modules/.cache
.next/cache
key: perf-${{ runner.os }}-${{ hashFiles('**/package-lock.json', '**/lighthouserc.json', '**/wpt-config.json') }}
restore-keys: |
perf-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
perf-${{ runner.os }}-
- run: npm ci
- run: npm run build
Keep separate cache steps for dependencies versus browser binaries so a framework upgrade does not invalidate the (much larger) binary cache, and vice versa. Across a fan-out matrix, scope keys per profile only if a variant installs different binaries; otherwise a shared key lets every parallel job reuse one warm cache.
CI Gating Assertion
Caching is an optimization, not a correctness control — so the budget gate still runs unconditionally after the cached install. The assertion below is the same one the audit enforces; caching only changes how fast it gets there.
- name: Run Lighthouse CI (gates on budget)
run: npx lhci autorun
env:
LHCI_TOKEN: ${{ secrets.LHCI_TOKEN }}
# lighthouserc.json assertions remain the source of truth:
# "metric-lcp": ["error", { "maxNumericValue": 2500 }]
# "first-contentful-paint": ["error", { "maxNumericValue": 1500 }]
# "cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }]
# "total-blocking-time": ["error", { "maxNumericValue": 200 }]
If a budget assertion fails, purge the stale cache so the next run regenerates artifacts rather than re-serving the regressed build:
#!/usr/bin/env bash
set -euo pipefail
if [ "${LH_EXIT_CODE}" -ge 1 ]; then
KEY="perf-${RUNNER_OS}-$(sha256sum package-lock.json | awk '{print $1}')"
gh api -X DELETE \
"repos/${GITHUB_REPOSITORY}/actions/caches?key=${KEY}" \
&& echo "Purged stale cache ${KEY}"
fi
Verification
Confirm three things after wiring the cache. First, the cache-hit output reads true on the second run of an unchanged branch. Second, the hit rate across the last ten runs exceeds 85%. Third, wall-clock actually dropped.
# Average matrix job duration over the last 10 runs, in seconds
gh run list --workflow perf-matrix.yml --limit 10 \
--json createdAt,updatedAt -q \
'[.[] | (((.updatedAt|fromdate) - (.createdAt|fromdate)))] | add/length'
A healthy result on the representative repo: full PR check down from 14.2 to 6.8 minutes (~52% faster), npm and binary steps reporting cache-hit: true, and the budget gate still firing on every run. If the duration has not moved, the keys are unstable — recheck that hashFiles() resolves to an identical hash across commits.
Frequently Asked Questions
Should I cache node_modules directly?
Prefer caching ~/.npm (the download store) and running npm ci over caching node_modules wholesale. npm ci guarantees a clean, lockfile-exact install, while a restored node_modules can carry platform-specific or partially-installed state that produces subtle, hard-to-reproduce CI failures.
Why is my cache hit rate stuck near zero?
Almost always an unstable key. If the key embeds github.sha, a timestamp, or a branch name, it changes every run and can never hit. Base the key on hashFiles('**/package-lock.json') plus your config files, and add restore-keys for partial fallback.