Speed is not vanity — a slow pipeline means slow feedback, frustrated developers, and wasted money. This lesson teaches you to make pipelines fast by caching intelligently.

Why Speed Matters

Developer Feedback Loop Fast CI (~2 min) Push → result while still thinking about the change → Fix immediately Slow CI (~15 min) Push → context switch → forget what you changed → Expensive to fix
Under 5 minutes = developer stays in flow. Over 10 minutes = they context-switch and lose the thread.
Target: CI under 5 minutes. Every second you save is multiplied by every developer, every push, every day. A 60-second improvement across 50 devs pushing 5 times/day = 4+ hours saved daily.

How Caching Works

Without caching, every job downloads ALL dependencies from scratch. With caching, dependencies are stored between runs and restored instantly.

First Run (No Cache) checkout 2s npm ci 45s ← SLOW test 10s 💾 Save to cache (key: hash of package-lock.json) Total: ~57 seconds Next Run (Cache Hit ✓) checkout 2s npm ci 8s ← CACHED! test 10s 📂 Restore from cache (key matched!) Total: ~20 seconds (3× faster) How Cache Keys Work key: deps-${{ runner.os }}-${{ hashFiles('package-lock.json') }} Exact match: Same lock file → cache hit → skip install Lock file changed: New deps added → cache miss → full install → save new cache
The cache key is a fingerprint. Same fingerprint = same dependencies = skip download.

Three Caching Methods

Method 1: Built-in Cache (Simplest)

Many setup-* actions have built-in caching:

# Node.js — caches ~/.npm
- uses: actions/setup-node@v4
  with:
    node-version: '20'
    cache: 'npm'         # ← This one line adds caching!

Method 2: Explicit Cache Action (More Control)

Cache ANY directory with custom keys:

- name: Cache node_modules
  id: cache
  uses: actions/cache@v4
  with:
    path: node_modules              # What to cache
    key: modules-${{ hashFiles('package-lock.json') }}

- name: Install dependencies
  if: steps.cache.outputs.cache-hit != 'true'  # Skip if cached!
  run: npm ci

Method 3: Docker Layer Cache (For Container Builds)

- uses: docker/build-push-action@v5
  with:
    cache-from: type=gha           # Pull cache from GitHub Actions
    cache-to: type=gha,mode=max    # Push cache back

Choosing the Right Caching Strategy

What are you caching? Language deps (npm, pip, go) → Built-in cache setup-node cache: 'npm' Easiest, zero config Build output / node_modules → actions/cache@v4 Custom path + key Skip install step entirely Docker image layers → type=gha cache Buildx + GHA cache backend Huge savings on rebuilds

🏋️ Exercise: Add Explicit Caching

Upgrade your CI workflow to cache node_modules directly (even faster than npm cache):

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'

      - name: Cache node_modules
        id: cache
        uses: actions/cache@v4
        with:
          path: node_modules
          key: modules-${{ hashFiles('package-lock.json') }}

      - name: Install (only on cache miss)
        if: steps.cache.outputs.cache-hit != 'true'
        run: npm ci

      - run: npm test

Test it:

  1. Push → first run will be slow (cache miss, full install)
  2. Push again without changing package-lock.json → check the "Cache node_modules" step — it should say "Cache restored" and the install step should be skipped
  3. Compare run times between the two!

Other Optimization Techniques

TechniqueSavesWhen to Use
Caching 30-60s per job Always (no reason not to)
Parallelism Minutes (run N jobs at once) Independent checks (lint, test, scan)
Concurrency cancel Entire wasted runs Rapid pushes to same branch
Path filters Entire pipeline skipped Mono-repos, docs-only changes
Test splitting Test time ÷ N shards Large test suites (>5 min)
Conditional steps Individual step time Steps only needed on main/PR

Cache Limits & Gotchas

  • 10 GB total per repository — oldest entries evicted first
  • 7 days — unused caches expire after a week
  • Branch scoping: caches from main are available to feature branches, but not vice versa
  • Key must be exact: if you change package-lock.json, the old cache is useless (new key = miss)
Cache Visibility Rules main writes cache feature branch reads main's cache ✓ other feature branch can't read feature's cache ✗
Feature branches can read main's cache (common deps), but can't read each other's.

🧠 Recall Check

  1. What determines whether a cache is "hit" or "miss"?
  2. If you add a new npm package, what happens to the cache?
  3. Name the three caching methods and when you'd use each.
  4. Can a feature branch use caches created by main?
Reveal answers
  1. The cache key. If the key (e.g., hash of package-lock.json) matches an existing cache entry, it's a hit. Otherwise, miss.
  2. Cache miss. Adding a package changes package-lock.json → different hash → different key → full install → new cache saved.
  3. Built-in (setup-node cache: 'npm') for simple dep caching. actions/cache for custom paths with skip logic. Docker GHA cache for container layer caching.
  4. Yes. Branches inherit caches from their base branch (main). But not from sibling branches.
Caching is the single highest-impact optimization. Add it to every pipeline, always. It's free performance. Next: managing sensitive data with Secrets, Environments, and Variables.