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))}]}' 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.
- 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.
Configuration Reference Jump to heading
| Setting | Typical default | Effect | When to change |
|---|---|---|---|
fetch-depth | 1 | How much history the job gets | Raise only for jobs needing a merge base or tags |
| Cache key | manual | What a cache hit promises | Always hash the lockfile |
restore-keys | none | Fallback when the key misses | Narrow prefixes only; never across trust levels |
npm ci vs install | install | Whether the lockfile is authoritative | Always ci in a pipeline |
| Job dependencies | serial | What can run at once | Declare only real dependencies |
| Concurrency group | none | Cancels superseded runs | Set per branch to stop wasted runs |
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
| Cache never hits | Key includes something that changes every run | Hash only the lockfile and the runner OS |
| Cache hits but the build fails | Restore key too broad, wrong tree restored | Remove the widening fallback |
| Policy job cannot find the merge base | Shallow checkout | Fetch the base branch specifically |
Version string is 0.0.0 in CI | Tags were not fetched | fetch-depth: 0 or fetch tags explicitly |
| Parallel jobs are slower than serial | Each job re-installs dependencies | Share a cache and restore, do not reinstall |
| Duration varies wildly run to run | Runner contention or cache eviction | Measure 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.
Related Jump to heading
- Caching Dependencies Keyed on the Lockfile — getting the key right, including the fallback rules.
- Shallow Clone vs Full History in CI — what breaks at depth one and how to fetch just enough.
- Reusing a Git Mirror on Self-Hosted Runners — making the fetch phase nearly free.
- Splitting a Long Pipeline Into Parallel Jobs — finding the real critical path.
- Fixing Cache Poisoning Between Branches — the security boundary a shared cache crosses.