A workflow that took 4 minutes last month now takes 19. Walk me through diagnosing and fixing GitHub Actions performance.
Quick Answer
Pull timing data per step from recent runs and diff against a fast month-old run — the regression is usually cache misses (key drift, eviction), dependency growth, runner queueing (not execution) time, or a new step someone added. Fix the dominant term: restore cache hit rate, split/parallelize jobs, or right-size runners.
Detailed Answer
Diagnose with data, not folklore: the run timeline shows per-step durations — export a few fast-vs-slow runs and diff. Common regressions in rough frequency order: (1) cache misses — a lockfile-hash cache key that now never hits because someone changed the key expression, the 10GB repo cache limit evicting your entry, or restore-keys falling back to stale bases that download everything anyway; check the cache step's logs for hit/miss and the cache usage page for evictions; (2) queue time counted as duration — for self-hosted runners, jobs wait for capacity; measure queued-vs-running separately before optimizing execution; (3) dependency creep — node_modules or Docker layers grew; multi-stage builds with registry layer caching (build-push-action with cache-from/cache-to) usually recovers it; (4) an added step — security scans and E2E suites land in the critical path silently; move them to parallel jobs or merge-queue-only workflows; (5) matrix explosion — someone added a dimension and 4 jobs became 24, saturating concurrency. Structural fixes: split lint/test/build into parallel jobs with needs only where true dependencies exist, cache at the right granularity, and for monorepos gate jobs with paths filters so unrelated changes don't run everything.
Code Example
# see hit/miss truthfully
- uses: actions/cache@v4
with:
path: ~/.pnpm-store
key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
# docker layer cache to registry
- uses: docker/build-push-action@v6
with:
cache-from: type=registry,ref=ghcr.io/org/app:cache
cache-to: type=registry,ref=ghcr.io/org/app:cache,mode=maxInterview Tip
Separate queued time from execution time out loud — teams routinely 'optimize' scripts when the actual regression is runner capacity. That distinction reads as real operational experience.