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
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.
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
🏋️ 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:
- Push → first run will be slow (cache miss, full install)
- 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
- Compare run times between the two!
Other Optimization Techniques
| Technique | Saves | When 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
mainare 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)
🧠 Recall Check
- What determines whether a cache is "hit" or "miss"?
- If you add a new npm package, what happens to the cache?
- Name the three caching methods and when you'd use each.
- Can a feature branch use caches created by
main?
Reveal answers
- The cache key. If the key (e.g., hash of package-lock.json) matches an existing cache entry, it's a hit. Otherwise, miss.
- Cache miss. Adding a package changes package-lock.json → different hash → different key → full install → new cache saved.
- 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.
- 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.