How does caching work in GitHub Actions and how do you optimize build times?
Quick Answer
GitHub Actions caching uses the actions/cache action to store and restore dependency directories (node_modules, .m2, pip cache) across workflow runs using content-addressable keys derived from lockfiles. Cache hits skip expensive install steps. Optimization also includes Docker layer caching, parallel jobs, conditional path filtering, artifact reuse between jobs, and incremental builds to reduce overall pipeline duration.
Detailed Answer
Caching in CI is like a chef's mise en place — the prep work of washing, chopping, and measuring ingredients before service begins. Without it, every dinner service (workflow run) starts from raw ingredients (downloading and compiling every dependency from scratch). With proper mise en place (caching), most of the prep is already done and you only re-prep what changed — a new spice (updated package) means re-measuring just that ingredient while everything else stays ready. GitHub Actions caching provides this pre-staged starting point for your builds.
The actions/cache action works with three parameters: path (what to cache), key (how to identify the cache), and restore-keys (fallback keys for partial matches). When a job starts, the action computes the cache key — typically a hash of the lockfile like 'deps-linux-${{ hashFiles('package-lock.json') }}'. It queries GitHub's cache storage API for an exact match. On a hit, the cached directory is downloaded and extracted, skipping the install step entirely. On a miss, it falls back to restore-keys prefix matching to find the most recent partial match, which still saves time because only changed packages need updating. After the job completes, if the exact key was not found initially, the current directory state is uploaded as a new cache entry.
Under the hood, GitHub stores caches in Azure Blob Storage, scoped to the repository and branch. Caches are immutable once created — a key can never be updated, only replaced when the key changes. The cache is accessible to all branches, but a branch can only restore caches created by itself or its parent branch (including the default branch), preventing cache poisoning between unrelated branches. The total cache storage per repository is 10 GB, managed via LRU eviction — caches not accessed within 7 days are automatically purged. Each individual cache entry has a maximum size of approximately 10 GB. The cache download and upload use concurrent chunked transfers for performance, and the action compresses the directory using zstd (or gzip as fallback) before upload.
Beyond dependency caching, production optimization involves multiple strategies. Docker layer caching via 'cache-from: type=gha' in docker/build-push-action stores intermediate layers in GitHub's cache, dramatically speeding up image builds when only application code changes. Path-based triggers ('on.push.paths') prevent entire workflows from running when irrelevant files change. Splitting workflows into smaller, focused pipelines (lint, test, build, deploy) with job dependencies lets unaffected stages complete quickly. Using 'actions/setup-node' with its built-in cache option simplifies the common case. For monorepos, tools like 'dorny/paths-filter' determine which packages changed and skip unchanged ones. Artifact passing between jobs with actions/upload-artifact avoids rebuilding in later stages. Incremental compilation with tools like turborepo or nx caches build outputs per-package.
The primary gotcha is cache key design. Using a key too broad (like 'deps-linux') means the cache never updates when dependencies change, leading to stale node_modules and mysterious build failures. Using a key too specific (including the run ID) means you never get cache hits. The sweet spot is hashing the lockfile, giving you exact matches when dependencies are unchanged and natural invalidation when they change. Another trap is caching node_modules directly instead of the npm cache directory — node_modules contains platform-specific binaries that may not work if the runner OS changes. The recommended approach is caching '~/.npm' and running 'npm ci', which reinstalls from the cache. Also beware that cache restores in matrix builds share the same keys by default, causing race conditions where multiple matrix jobs try to save the same cache key simultaneously — only one succeeds.