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.