Reinstalling every dependency from scratch on every single run is one of the largest and most avoidable sources of slow CI. Most platforms support caching a directory keyed by a hash of whatever determines its contents:
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
npm-
- run: npm ci
The key here changes exactly when package-lock.json changes — as long as dependencies haven't changed, the cache hits and npm ci restores from the cached download cache instead of fetching everything over the network again. restore-keys provides a fallback: if there's no exact match for the current lockfile hash (a new dependency was just added), it can still restore the most recent cache with a matching prefix and only need to fetch the new packages, rather than starting from nothing.
This is exactly analogous to Docker's layer caching (see Docker Fundamentals) — both are "skip the work if the relevant input hasn't changed" applied to a different stage of the pipeline, and both share the same failure mode: a cache key that's too broad (doesn't change when it should) serves stale results, while one that's too narrow (changes when it shouldn't) never actually hits.