Skip to content
7 min read·Lesson 7 of 8

Caching, Artifacts, and Performance Optimization

Cut workflow runtimes and bills with dependency caching, build artifacts, and the right concurrency/job-shape decisions.

A clean CI run takes a few minutes. A slow one takes 20+ — and developers stop trusting it. This lesson covers the three biggest performance levers: caching, artifacts, and pipeline structure.

Caching

The single highest-impact optimisation is caching the dependency install step. Without caching, every CI run downloads node_modules, the pip cache, the Go module cache, the Maven cache, etc. With caching, you download only the diff.

Using actions/cache directly

- name: Cache npm
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      ${{ runner.os }}-npm-

The cache key is the lockfile hash — when the lockfile changes, the cache misses (correctly) and a new entry is created. The restore-keys: prefix list lets a partial match restore an older cache as a starting point.

Built-in caching via setup actions

The official language setup actions wrap cache logic for you:

- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: 'npm'              # restore + save npm cache, keyed on package-lock.json

- uses: actions/setup-python@v5
  with:
    python-version: '3.12'
    cache: 'pip'

- uses: actions/setup-go@v5
  with:
    go-version: '1.23'
    cache: true

Always prefer these built-ins over actions/cache when they exist. They get the key logic right.

Cache Behaviour

  • Total cache storage per repository: 10 GB (LRU eviction beyond that)
  • Caches are keyed per branch and per fork — a feature branch can't pollute main's cache
  • Cache restore from main is allowed in PRs from the same repo (not forks)
  • Unused caches expire after 7 days

Artifacts

Artifacts pass files from one job to another in the same workflow run:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm run build
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/
          retention-days: 7

  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/download-artifact@v4
        with:
          name: dist
          path: dist/
      - run: ./deploy.sh

Artifact vs Cache — when to use which

Use caseTool
Pass build output to a deploy jobArtifact
Reuse node_modules between runsCache
Keep test reports for human reviewArtifact
Speed up future builds with a Docker layerCache (or registry-based BuildKit cache)
Share a built container imagePush to a registry — neither

Artifact Retention

Default 90 days. For chatty CI that produces large artifacts on every PR, shorten it:

- uses: actions/upload-artifact@v4
  with:
    name: test-results
    path: junit/
    retention-days: 14

Artifacts count against your account's storage quota — keeping them lean saves money.

Pipeline Structure

Caching shaves minutes; pipeline shape can shave half the wall-clock time. Decisions that compound:

Parallelise independent jobs

jobs:
  lint:     { runs-on: ubuntu-latest, steps: [...] }
  type-check: { runs-on: ubuntu-latest, steps: [...] }
  unit:     { runs-on: ubuntu-latest, steps: [...] }
  e2e:      { runs-on: ubuntu-latest, steps: [...] }
  # No needs: between them → they all start at once

If lint, type-check, unit, and e2e each take 4 minutes, sequentially the workflow takes 16 minutes; in parallel, 4.

Split slow jobs

A 12-minute test job that's CPU-bound is often a 3-minute job × 4 shards. Use a matrix variable for the shard index:

strategy:
  matrix:
    shard: [1, 2, 3, 4]
steps:
  - run: npm test -- --shard=${{ matrix.shard }}/4

Use path filters to skip unnecessary work

on:
  push:
    paths:
      - 'src/**'
      - 'package*.json'
      - '.github/workflows/ci.yml'

Changes that only touch README.md shouldn't run the full test matrix.

Concurrency to cancel superseded runs

concurrency:
  group: ci-${{ github.ref }}
  cancel-in-progress: true

If a developer pushes three times in a minute, only the last push needs to fully run. The first two cancel.

Measuring

The Actions UI shows per-job and per-step duration. For a deeper view, the Insights → Actions tab (Enterprise) shows usage and time-to-feedback trends. There's also actions-runner-controller metrics for self-hosted fleets.

Practical Optimisation Order

  1. Enable language-setup cache (5-minute change, often saves minutes per run)
  2. Parallelise the obvious independent jobs
  3. Add path filters to skip irrelevant paths
  4. Add concurrency: cancel-in-progress for PR workflows
  5. Shard tests if a single job is still > 5 minutes
  6. Cache Docker layers via BuildKit's --cache-to type=gha
  7. Consider larger runners or self-hosted only after the above

Key Takeaways

  • actions/cache stores and restores arbitrary paths keyed by a hash — typically of your lockfile.
  • Setup actions (setup-node, setup-python, setup-go) have built-in cache support — use it.
  • Artifacts pass files between jobs in the same run; cache reuses files across runs.
  • Artifacts default to 90-day retention; configure shorter retention for noisy CI runs.
  • Right-sizing the job DAG often saves more time than caching — parallelise what you can.

Test your knowledge

Try exam-style practice questions to reinforce what you've learned.

Practice Questions →