CI Caching & Runner Performance Jump to heading

Pipeline duration multiplies. Every pull request pays it, every push pays it again, and a merge queue pays it once per candidate tree — so a four-minute saving on a pipeline that runs two hundred times a week is thirteen hours of wall-clock time returned to a team every week. Yet most optimisation effort goes into the tests, which are frequently not where the time is. This part of Git Automation & CI/CD Hook Engineering is about measuring first and then attacking the three phases that usually dominate: fetching the repository, restoring dependencies, and waiting for jobs that could have run in parallel.

Prerequisites Jump to heading

Measure Before Changing Anything Jump to heading

The phases people assume are slow and the phases that are slow are rarely the same. Pull the step timings and look.

# Total duration of recent runs, to establish a baseline
gh run list --workflow ci.yml --limit 30 --json createdAt,updatedAt \
  --jq '[.[] | ((.updatedAt | fromdate) - (.createdAt | fromdate))] | add / length | floor'
# Where the time goes inside one run
gh run view "$RUN_ID" --json jobs \
  --jq '.jobs[] | {job: .name,
        steps: [.steps[] | {name, seconds: ((.completedAt | fromdate) - (.startedAt | fromdate))}]}'
Where the minutes go in a typical unoptimised pipelineDependency installation and repository fetch together account for more than half the duration before a single test runs. The test suite, which attracts most of the optimisation attention, is the smaller half.seconds per phase, median of thirty runsdependency install96 srepository fetch41 stest suite78 sbuild34 supload artefacts11 stwo of the top three bars are cacheable; the tests are the part that is supposed to take time

Step 1 — Key the Dependency Cache on the Lockfile Jump to heading

A cache key is a promise that identical keys mean identical contents. Keying on a branch name or a date breaks that promise and produces the worst outcome available: a cache that restores the wrong thing and a build that fails somewhere unrelated.

# The key is the hash of the file that determines the tree
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'                      # keys on package-lock.json automatically
      - run: npm ci                         # never `npm install` in CI
# Verification: the same lockfile must produce the same key
sha256sum package-lock.json
git stash && sha256sum package-lock.json && git stash pop

npm ci rather than npm install is half the benefit: it installs exactly what the lockfile says and fails if the manifest disagrees, which turns a silent drift into a build error. The same discipline as keeping lockfiles conflict-free during bulk updates.

Step 2 — Fetch Only the History the Job Needs Jump to heading

A full clone of a mature repository transfers years of objects to run a test suite that reads one commit. But a shallow clone breaks anything that needs a merge base, which includes most policy checks.

Shallow clone against full history, per job typeA test job needs one tree and nothing else, so a depth-one fetch is both correct and fast. A policy job comparing a pull request against its base needs the merge base, which a shallow clone does not contain.Shallow (depth 1)Full historytest jobcorrect and fastwastefuldiff against basefails — no merge basecorrectversion from tagsfails — no tagscorrectfetch timesecondstens of secondschoose per job, not per repository — most jobs are the first row
      - uses: actions/checkout@v4          # defaults to depth 1
      # ...but a job that diffs against the base needs more:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
# Verification: does this job have what it needs?
git rev-parse --is-shallow-repository
git merge-base origin/main HEAD >/dev/null 2>&1 && echo "merge base available"

A middle option exists and is usually the right one for policy jobs: fetch the base branch specifically rather than all history. The full treatment is in shallow clone vs full history in CI.

Step 3 — Split the Pipeline Along Real Dependencies Jump to heading

Most pipelines are serial because they grew that way, not because each step needs the one before it. Draw the actual dependency graph and the parallelism is usually obvious.

jobs:
  install:                      # one restore, shared downstream
    outputs: { cache-key: "${{ steps.k.outputs.key }}" }
  lint:    { needs: install }
  test:    { needs: install }
  build:   { needs: install }
  package: { needs: [test, build] }        # genuinely depends on both
# Verification: the critical path should be shorter than the sum of the jobs
gh run view "$RUN_ID" --json jobs \
  --jq '{wall: ((.jobs | map(.completedAt | fromdate) | max) - (.jobs | map(.startedAt | fromdate) | min)),
         sum: ([.jobs[] | ((.completedAt | fromdate) - (.startedAt | fromdate))] | add)}'

Step 4 — Scope Caches So Branches Cannot Poison Each Other Jump to heading

A shared cache is a shared mutable resource, and a cache written by one branch and read by another is a supply-chain path into every build. Restore keys must narrow, never widen, across trust boundaries.

SAFETY WARNING — never let a pull request from a fork write to a cache that the default branch reads. An attacker who can populate a cache entry can place arbitrary content into a later trusted build, and nothing in the build output reveals it. Scope write access to the default branch, allow forks to read only, and treat a cache hit as untrusted input if it can be written by a less trusted context. The diagnostic pattern is in fixing cache poisoning between branches.

      - uses: actions/cache@v4
        with:
          path: ~/.npm
          key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            npm-${{ runner.os }}-
# Verification: list what exists and who wrote it
gh cache list --limit 20

Step 5 — Re-use a Repository Mirror on Self-Hosted Runners Jump to heading

Where you control the runners, the repository fetch can be nearly free: a local mirror updated once, with every job cloning from disk.

# On the runner host, once
git clone --mirror https://github.com/acme/app.git /srv/git/app.git

# Kept fresh by a timer
git --git-dir=/srv/git/app.git remote update --prune

# In the job
git clone --reference /srv/git/app.git --dissociate "$REPO_URL" work
# Verification: object count transferred should be small
GIT_TRACE_PACKET=0 git clone --reference /srv/git/app.git --dissociate "$REPO_URL" /tmp/w 2>&1 | grep -i 'receiving\|total'

--dissociate copies the borrowed objects into the new clone so the working copy does not depend on the mirror continuing to exist — the detail that makes this safe to use in a disposable job. The background on repository size is in large repository performance.

Four levers, applied in the order that paysMeasure first, then cache dependencies, then fetch only the history each job needs, then parallelise along the real dependency graph. Each step is independently useful and the order reflects effort against return.Measureper-step timingsCache depskeyed on the lockfileTrim the fetchper job, not per repoParallelisereal dependencies onlyskipping the first box is how teams optimise the test suite that was never the bottleneck

Configuration Reference Jump to heading

SettingTypical defaultEffectWhen to change
fetch-depth1How much history the job getsRaise only for jobs needing a merge base or tags
Cache keymanualWhat a cache hit promisesAlways hash the lockfile
restore-keysnoneFallback when the key missesNarrow prefixes only; never across trust levels
npm ci vs installinstallWhether the lockfile is authoritativeAlways ci in a pipeline
Job dependenciesserialWhat can run at onceDeclare only real dependencies
Concurrency groupnoneCancels superseded runsSet per branch to stop wasted runs

Troubleshooting Jump to heading

SymptomLikely causeFix
Cache never hitsKey includes something that changes every runHash only the lockfile and the runner OS
Cache hits but the build failsRestore key too broad, wrong tree restoredRemove the widening fallback
Policy job cannot find the merge baseShallow checkoutFetch the base branch specifically
Version string is 0.0.0 in CITags were not fetchedfetch-depth: 0 or fetch tags explicitly
Parallel jobs are slower than serialEach job re-installs dependenciesShare a cache and restore, do not reinstall
Duration varies wildly run to runRunner contention or cache evictionMeasure the cache hit rate before tuning anything

The Costs That Caching Cannot Remove Jump to heading

Three phases resist caching entirely, and recognising them early stops a lot of wasted tuning. The first is runner acquisition: the time between a job becoming eligible to run and a machine picking it up. On a hosted pool under load that can exceed the job itself, and no configuration in your repository changes it — the levers are concurrency limits, a larger pool, or self-hosted capacity.

The second is container image pull. A job that specifies a large image pays for it on every cold runner, and the image is usually far bigger than the dependencies it was chosen to provide. Auditing the image is frequently a larger win than auditing the cache: a two-gigabyte image containing three toolchains, two of which the job never invokes, is a common finding.

The third is test execution itself, which is supposed to take time. Caching test results is possible for genuinely deterministic suites keyed on the source tree, but the key has to capture every input — environment variables, fixture data, the test runner version — and a key that misses one of them serves a stale pass. That failure is silent and expensive, so most teams are better served by making tests faster than by trying not to run them.

# How long jobs wait before a runner picks them up
gh run view "$RUN_ID" --json jobs   --jq '.jobs[] | {job: .name,
        queued: ((.startedAt | fromdate) - (.createdAt | fromdate))}'
# Image size, which is paid on every cold runner
docker image inspect "$IMAGE" --format '{{.Size}}' 2>/dev/null   | awk '{ printf "%.1f GB\n", $1/1073741824 }'

Once the cacheable phases are handled, the honest next question is whether the pipeline should run at all for a given change. A documentation-only pull request that triggers a full build is paying every one of these costs for no information, which is why path-based triggers usually outperform any amount of cache tuning. That design is covered in optimizing CI triggers for path-specific changes, and cancelling runs that have been superseded by a newer push is covered in cancelling superseded CI runs with concurrency groups. Between them they remove more machine time from a busy repository than caching does, because the fastest phase is the one that never starts.

Frequently Asked Questions Jump to heading

Why is my cache hit rate low even though the key looks right? Jump to heading

Most providers evict caches by age and total size, and a busy repository with per-branch keys can churn through the quota in a day. Fewer, broader keys — one per lockfile hash rather than one per branch — usually raise the hit rate more than any key tweaking.

Is it worth caching the build output as well as dependencies? Jump to heading

Only if the build is deterministic and the cache key captures every input, which is harder than it sounds. A build cache keyed on the source hash alone will serve stale output the first time a compiler version changes. Start with dependencies, where the inputs are one file.

Do self-hosted runners actually make this faster? Jump to heading

They remove the repository fetch and the cold dependency download, which is most of the fixed cost — but they add maintenance and a security boundary you now own. For a busy repository the trade is usually worth it; for an occasional one it is not.

How does caching interact with a merge queue? Jump to heading

A queue multiplies pipeline runs, so caching matters more there than anywhere else. Make sure the cache key does not include the branch name, or every candidate branch misses the cache and the queue pays the cold cost every time — see batching pull requests to cut CI cost.