- name: Cache npm uses: actions/cache@v4 with: path: ~/.npm key: npm-${{ hashFiles('**/package-lock.json') }} restore-keys: | npm-- run: npm ciA Node install in GitHub Actions runs npm ci against the registry on every push, and that download costs you 3 to 4 minutes a run. The actions/cache step above stores ~/.npm between runs and hands it back on a hit, which drops the same install to 15 to 30 seconds. You add six lines and reclaim most of your install time. Three changes build on that step, and a team can land all three in an afternoon.
Cache on the lock file hash
The cache key decides whether you get a hit. Key it to hashFiles('**/package-lock.json') so the hash changes only when a dependency changes. Most teams touch the lock file a few times a month, so this key lands a hit on 70 to 90 percent of runs. The dependency-caching walkthrough on dev.to measures the same drop: a cold npm install of 3 to 4 minutes falls to under 30 seconds once the cache restores.
Cache ~/.npm, the global npm download cache, and let npm ci assemble node_modules from it. Caching node_modules itself looks faster, and it breaks the moment you add a Linux and Windows matrix, because the tree holds platform-specific binaries that don’t transfer across runners. Easton’s cache-strategy post walks the same trade-off and lands on caching the download directory for the same reason.
Add restore-keys for partial hits
A single dependency bump rewrites the lock file hash, and your exact key misses. Without a fallback the run pays the full cold install. restore-keys gives you the recovery path:
- name: Cache npm uses: actions/cache@v4 with: path: ~/.npm key: npm-${{ hashFiles('**/package-lock.json') }} restore-keys: | npm-On a miss, the cache matches the npm- prefix and restores the most recent entry under it. npm ci then downloads the handful of packages that changed and reuses the rest. You skip 80 percent of the downloads on a run that would have started from zero. The build-cache techniques roundup treats this partial-restore pattern as the default for any lock-file-driven cache.
Cache Docker layers, then order the Dockerfile
A container build that rebuilds every layer runs 8 minutes or more. The GitHub Actions cache backend stores Docker layers once you point buildx at it:
- name: Build image uses: docker/build-push-action@v6 with: cache-from: type=gha cache-to: type=gha,mode=maxmode=max caches every intermediate layer, not the final image alone, so an unchanged base and dependency layer restore from the cache and the build jumps to your changed code. The same container build drops to under 2 minutes.
Layer order decides the payoff. Put the instructions that change least often near the top of the Dockerfile and the commit-specific copy near the bottom:
FROM node:24-slimWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run buildDocker invalidates every layer below the first one that changes. Copy package*.json and run npm ci before you copy the source, and a code-only commit reuses the dependency layer. Reverse the two lines and every commit reinstalls from scratch. The CI/CD performance-optimization writeup ties the same ordering rule to its largest single saving.
Count the minutes a cold run spends downloading and rebuilding. That number is your ceiling, and dependency plus layer caching takes most of it back.
Where the gains compound
Stack the three and the numbers move together. One pipeline-optimization roundup records 45-minute pipelines dropping to 8 and 15-minute Node pipelines to under 3 once caching covers both dependencies and Docker layers.
A monorepo extends the same idea past a single repo. Turborepo’s remote cache shares task output across every CI run and every developer, so one job reuses a build another machine already ran. Mercari’s engineering team enabled remote caching in February 2026 and reported Turbo task duration down 50 percent and total job duration down 30 percent.
One caveat rides along with heavy caching: a stale or poisoned cache serves the wrong artifact and hides a real failure. Datadog’s team documents purging the CI cache when a build behaves in a way the source doesn’t explain. Bump a version segment in your cache key when you need a clean slate, and tie the key to the lock file the rest of the time.
Start with the lock file step
You don’t need the whole stack on day one. Open your workflow, add the six-line actions/cache step before npm ci, key it to the lock file hash, and commit. That one step cuts install time by 60 percent or more before you touch Docker or your monorepo tooling. Add restore-keys next, then layer caching once a container build sits on your critical path.
I first shared this on LinkedIn.