
Table of Contents
By Khimananda Oli | Last reviewed: August 2026
Slow builds are the most common bottleneck when teams try to cache Node.js dependencies in CI pipelines. Without proper caching, every run downloads hundreds of megabytes from registries, wasting minutes and inflating cloud bills. This guide provides exact, lockfile-aware configurations for GitHub Actions, GitLab CI, and Jenkins that work reliably in production.
How does dependency caching work in CI environments?
Dependency caching replaces network I/O with local disk or object storage retrieval. When you configure a pipeline to cache Node.js dependencies in CI pipelines, the system creates an archive of your node_modules directory or package manager store after a successful install. On subsequent runs, it checks for a matching key—usually derived from your lockfile—and restores the archive before running npm install.
The critical distinction is between exact matches and prefix matches. An exact match means the lockfile hasn't changed at all; restoration is instant and safe. A prefix match (e.g., npm-linux-) allows partial restoration when dependencies have been added or updated, letting the package manager only fetch the delta rather than starting from zero. For teams managing infrastructure across regions like Nepal where registry latency can be higher, this fallback strategy prevents 3+ minute penalties on every dependency update.
How do you configure caching in GitHub Actions for npm, Yarn, and pnpm?
GitHub Actions provides the actions/setup-node action with built-in caching support. This is the recommended approach over manual actions/cache configurations because it automatically detects your package manager and uses the correct store paths.
npm caching configuration
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci The cache-dependency-path parameter is mandatory for monorepos or non-standard layouts. Without it, the action searches the repository root and may miss nested lockfiles. Always use npm ci instead of npm install in CI; it respects the lockfile exactly and fails if there's a mismatch, preventing silent drift.
Yarn Berry (v4+) caching
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'yarn'
cache-dependency-path: yarn.lock
- name: Install dependencies
run: yarn install --immutable Yarn Berry uses a different storage model (.yarn/cache) than classic Yarn. The setup-node action handles this automatically when you specify cache: 'yarn', but verify your .yarnrc.yml has nodeLinker: node-modules if you're migrating from classic and need traditional node_modules compatibility.
pnpm caching with store optimization
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
cache-dependency-path: pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile pnpm's global content-addressable store makes it inherently faster for caching. The store lives outside the project directory, so multiple projects on the same runner share cached packages. See our guide on build caching strategies for deeper optimization techniques across different CI platforms.
What are the correct cache key strategies for GitLab CI and Jenkins?
GitLab CI and Jenkins require explicit cache configuration since they lack the integrated setup actions of GitHub. The principle remains identical: derive keys from lockfiles, not timestamps or branch names.
GitLab CI cache configuration
variables:
NPM_CACHE_DIR: "$CI_PROJECT_DIR/.npm-cache"
install:
stage: prepare
image: node:22-alpine
cache:
key:
files:
- package-lock.json
paths:
- .npm-cache/
policy: pull-push
script:
- npm config set cache $NPM_CACHE_DIR --global
- npm ci --prefer-offline The key.files directive hashes the specified file(s) automatically. Using policy: pull-push ensures the cache updates after every successful job. For feature branches that rarely modify dependencies, consider pull-only policies with a separate scheduled pipeline to refresh caches weekly.
Jenkins declarative pipeline caching
pipeline {
agent any
environment {
NPM_CONFIG_CACHE = "${WORKSPACE}/.npm-cache"
}
stages {
stage('Install') {
steps {
sh 'mkdir -p ${NPM_CONFIG_CACHE}'
sh 'npm config set cache ${NPM_CONFIG_CACHE} --global'
// Use Job Cacher plugin or stash/unstash
cache(caches: [
arbitraryFileCache(
path: '.npm-cache',
includes: '**/*',
cacheValidityDecidingFile: 'package-lock.json'
)
]) {
sh 'npm ci --prefer-offline'
}
}
}
}
} Jenkins requires the Job Cacher plugin for persistent caching across builds. Without it, workspace cleanup destroys cached artifacts. The cacheValidityDecidingFile parameter serves the same role as GitLab's key.files. Teams running self-hosted agents should ensure adequate disk allocation; see our self-hosted runner security guide for capacity planning advice.
| Feature | GitHub Actions | GitLab CI | Jenkins |
|---|---|---|---|
| Key derivation | Automatic via setup-node | key.files directive | cacheValidityDecidingFile param |
| Fallback prefix support | Built-in (restore-keys) | Manual via prefix key | Not native; custom logic |
| Cross-job sharing | Yes (workflow scope) | Yes (project/group scope) | Yes (with Job Cacher) |
| Max cache size | 10 GB per repo | Configurable per runner | Disk-dependent |
| TTL / eviction | 7 days unused | Configurable | Manual cleanup |
| Monorepo support | cache-dependency-path | Multiple key.files entries | Per-stage cache blocks |
Why is my CI cache missing or invalidating unexpectedly?
Cache misses usually stem from three issues: incorrect key computation, mutable state pollution, or platform-specific quirks. Debugging requires understanding what actually gets hashed.
- Lockfile not committed: If
package-lock.jsonis in.gitignoreor generated dynamically, the hash changes every run. Always commit lockfiles and verify withgit statusbefore pushing. - OS/architecture mismatch: Native modules (sharp, bcrypt, sqlite3) compile platform-specific binaries. Include
${{ runner.os }}or equivalent in your cache key to avoid restoring incompatible artifacts. - Post-install scripts modifying node_modules: Tools like patch-package or husky alter installed files after
npm ci. Either include these modifications in the cache save step or run them post-restore. - Stale cache from deleted branches: GitHub evicts caches after 7 days of no access, but GitLab and Jenkins may retain them indefinitely. Implement periodic cleanup jobs or TTL policies.
- Concurrent writes corrupting archives: Parallel matrix jobs saving to the same key simultaneously can produce corrupted tarballs. Use unique suffixes (
${{ matrix.node-version }}) or accept that only one winner persists.
A common mistake is caching node_modules directly instead of the package manager's store. While node_modules works, it's larger and more fragile. The npm cache (~/.npm/_cacache) or pnpm store contains deduplicated content-addressable blobs that restore faster and survive minor structure changes. Pair this with observability: monitor cache hit rates alongside your four golden signals to detect degradation before developers complain.
How do you optimize cache performance for monorepos and large projects?
Monorepos introduce complexity because different workspaces have independent dependency trees. Caching the entire root node_modules wastes space and causes unnecessary invalidation when unrelated packages update.
- Use workspace-aware tooling: Turborepo, Nx, and pnpm workspaces generate per-package lockfiles or task hashes. Configure CI to cache at the workspace level, not the repository root.
- Implement layered caching: Cache shared hoisted dependencies separately from workspace-specific ones. In pnpm, the global store handles this automatically; in npm workspaces, create separate cache entries for root vs. workspace lockfiles.
- Leverage remote caching: For teams with many CI runners or distributed offices (common in Nepal-based companies serving global clients), local runner caches don't share across machines. Tools like Turborepo Remote Cache or BuildBuddy provide S3-backed storage that all runners access.
- Prune before saving: Remove devDependencies from production caches using
npm prune --productionorpnpm deploy. This reduces archive size by 40–60% for typical web applications. - Set explicit size limits: Unbounded caches consume runner disk and slow uploads. Configure max sizes in your CI platform and monitor usage. Archive sizes over 500 MB indicate you're caching build outputs or test artifacts that belong in artifact storage instead.
Implementing sustainable dependency caching practices
Getting cache Node.js dependencies in CI pipelines right isn't a set-and-forget task. Treat it as part of your broader CI reliability engineering. Audit cache hit rates monthly; anything below 80% indicates key misconfiguration or excessive churn. Rotate stale caches quarterly to reclaim storage. Document your caching strategy alongside your CI/CD best practices so new team members understand why certain patterns exist.
Start with the platform-native solutions shown above—they cover 90% of cases without additional tooling. Only graduate to remote caches or custom orchestration when you've measured actual bottlenecks. If your team needs help auditing pipeline performance or designing compliant CI infrastructure, reach out to discuss your specific setup.