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. This page is part of the Lighthouse CI & WebPageTest Integration reference, and it sits under the GitHub Actions Performance Matrices guide, where you fan a single audit across several device and network profiles. When you fan out that way, the redundant install-and-download work multiplies by the number of variants, and a 4-minute install quietly becomes the dominant cost of the whole check.
The fix is targeted artifact caching: cache the static, slow-to-rebuild layers (the npm download cache, the Chrome or Playwright binaries, and the framework build cache) and leave the dynamic output that carries your budget signal uncached. The goal we hold ourselves to on a representative mid-size frontend repo is a PR check under 8 minutes with a cache hit rate above 85 percent — while every budget assertion still runs on every commit, so caching never masks a regression.
Where the Time Goes
Before caching anything, know which layers are worth caching. The table shows where the seconds go on a representative repo and what each cache layer recovers on a warm run. 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 |
The architecture below shows how the three cacheable layers restore into a matrix job while the build output stays uncached and flows straight into the budget gate. The gate runs unconditionally regardless of any cache hit, so a warm run is faster but never less strict.
The recovered-time picture is easier to read as a chart. Each pair compares the cold (miss) cost against the warm (hit) cost for one layer, in seconds. The npm and browser layers collapse almost to nothing on a warm run; the build cache shrinks but never disappears, because an incremental rebuild still does real work.
Diagnose Before You Optimize
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 and no amount of tuning downstream will help.
# 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, a run number, or a timestamp can never hit on a fresh PR, because it is unique to the run that wrote it.
# 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. Perceived speed is unreliable; the API timestamps are not.
gh run list --workflow perf-matrix.yml --limit 10 \
--json databaseId,conclusion,createdAt,updatedAt \
| jq -r '.[] | "\(.conclusion)\t\(.databaseId)"'
Implement the Cache Layers
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 past a gate that should have failed. The deterministic collection settings you pin when running Lighthouse CI on every pull request are exactly what makes these keys reproducible across commits.
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. The one variable that must stay identical across matrix legs is CPU throttling — if runners drift, cached runs still produce noisy numbers, which is why calibrating CPU throttling for CI runners belongs alongside this work.
How restore-keys Rescue a Partial Cache
The subtlety most teams miss is the difference between an exact key and the restore-keys fallback prefixes. On a miss for the exact key, actions/cache walks the restore-keys list top to bottom and restores the newest cache whose key starts with that prefix. That is how a lockfile bump — which changes the exact key — still recovers the browser binaries that did not actually change version. The fallback ladder below shows the resolution order for the perf- cache.
Keep the Gate Firing
Caching is an optimization, not a correctness control — so the budget gate still runs unconditionally after the cached install. The assertions below are lab-lab targets tuned for a mid-range mobile device on Fast 3G at the P75 mark: an LCP ceiling of 2500 ms, first contentful paint under 1500 ms, cumulative layout shift under 0.1, and total blocking time under 200 ms. Caching only changes how fast the job reaches those checks, never whether they run.
- 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 (P75, mid-range mobile / Fast 3G):
# "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
Verify the Win
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 percent. Third, wall-clock actually dropped — measure it, do not trust the feel of the UI.
# 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 percent 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. The decision tree below is the fastest way to localize which of the three symptoms you actually have.
Once the PR check is fast and green, the same cached workflow becomes cheap enough to run on a schedule — the foundation for scheduling nightly Lighthouse runs that catch drift no single PR would surface.
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.
Can caching hide a real performance regression?
Only if you cache the wrong layer. Never cache dist or .next compiled output, because a restored build can serve already-optimized assets past a gate that should have failed. Cache the npm store, browser binaries, and node_modules/.cache, and let every audit rebuild the output it measures against the P75 mid-range mobile budget.
How do restore-keys differ from the exact key?
The exact key must match byte for byte to hit. When it misses, actions/cache walks restore-keys top to bottom and restores the newest cache whose key starts with that prefix. That is how a lockfile bump still recovers unchanged browser binaries through a broader prefix like perf-Linux-.
Should each matrix variant get its own cache key?
Only when a variant installs genuinely different binaries. If every device and network profile shares the same dependencies, a single shared key lets all parallel jobs reuse one warm cache and pushes your hit rate above 85 percent. Scope per profile and you multiply cold misses across the fan-out.