Node.js projects often carry hundreds of dependencies. Without a cache, every
npm ci downloads everything from the npm registry, 30 to 60 seconds
minimum. With the cache, that goes down to a few seconds.
What you will learn
- Enable the built-in cache of
setup-nodefor npm, pnpm and yarn - Configure
actions/cachewhen you need control - Cache the builds of Next.js, Turborepo and ESLint
- Handle a monorepo with workspaces
- Avoid the traps of the
node_modulescache and of pnpm
The built-in cache of setup-node
The setup-node action can handle the cache on its own: one line is enough, it
computes the key from the lockfile and restores the package manager cache
before the install. Mind what is really cached: the download store (~/.npm),
not node_modules. So npm ci still runs, but with no network call to the
registry, which is where the gain sits.
The simplest method:
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20' cache: 'npm' # Detects package-lock.json automatically
- run: npm ciAutomatic detection of the manager
setup-node detects the package manager from the files that are present, but the
value of cache: is still up to you: it must match the lockfile committed
into the repository. A value of npm on a project that only has a
pnpm-lock.yaml makes the step fail, for lack of a file to hash.
| File present | Manager detected |
|---|---|
package-lock.json | npm |
pnpm-lock.yaml | pnpm |
yarn.lock | yarn |
# For pnpm- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20' cache: 'pnpm'
- run: pnpm install
# For yarn- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20' cache: 'yarn'
- run: yarn install --frozen-lockfileManual caching
For more control or for specific cases, actions/cache takes over: you choose
the path to keep, the exact key and the fallback restore-keys. It is
mandatory when you have to cache something other than the package manager store,
or share a cache between several jobs that do not call setup-node.
npm cache
The path of the npm store varies with the system and the version: reading it with
npm config get cache avoids hard-coding it and keeps the workflow valid on
ubuntu, macos and windows. The restore-keys act as a fallback when
the lockfile has changed, the closest cache being reused rather than starting
from scratch.
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20'
- name: Get npm cache directory id: npm-cache-dir shell: bash run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ${{ steps.npm-cache-dir.outputs.dir }} key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} restore-keys: | npm-${{ runner.os }}-
- run: npm cipnpm cache
The order of the two actions is not interchangeable: setup-node queries pnpm store path to know what to cache, so pnpm has to be installed already by the
time it runs.
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: version: 9
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20' cache: 'pnpm'
- run: pnpm install --frozen-lockfileyarn cache (v3+)
On Yarn Berry, --immutable replaces the old --frozen-lockfile and makes
the install fail if yarn.lock would have to be modified, exactly what you want
in CI.
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20' cache: 'yarn'
- run: yarn install --immutableCaching node_modules
For maximum gains, cache node_modules directly: on a cache hit, the install
is simply skipped, which removes the few seconds npm ci still spends writing
the tree. The if: condition on the cache-hit output is what makes the
approach viable: without it, you would restore the cache then reinstall on top of
it.
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20'
- name: Cache node_modules uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 id: cache-node-modules with: path: node_modules key: node-modules-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
- name: Install dependencies if: steps.cache-node-modules.outputs.cache-hit != 'true' run: npm ciCareful
This approach is faster but more fragile. The node_modules cache has to match
the lockfile exactly. And if you have postinstall scripts, they will not run on
a cache hit.
Caching the builds
Beyond the dependencies, the build tools keep their own cache on disk. Preserving it from one run to the next speeds up compilation considerably.
Next.js cache
Next.js keeps the already compiled modules and the optimised images in
.next/cache. The key here combines the lockfile and the hash of the
sources: it changes on every code modification, and the restore-keys then
provide the previous cache as a starting point, which limits the recompilation to
the changed files.
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | .next/cache key: nextjs-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx', '**/*.ts', '**/*.tsx') }} restore-keys: | nextjs-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}- nextjs-${{ runner.os }}-Turborepo cache
Turborepo indexes the result of every task in .turbo and replays it as it is
when the inputs have not moved: on a monorepo, only the changed packages are
rebuilt. The github.sha in the key guarantees a write on every run, the
restore-keys taking care of picking up the most recent cache.
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .turbo key: turbo-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-${{ github.sha }} restore-keys: | turbo-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}- turbo-${{ runner.os }}-ESLint cache
The .eslintcache file remembers the fingerprint of the files already analysed
without error; the --cache flag is mandatory on the command side, otherwise
nothing is read nor written. On a large repository, the lint then only covers the
changed files.
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: .eslintcache key: eslint-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
- run: npm run lint -- --cacheA complete workflow
This workflow assembles the previous building blocks in a real case: testing a
library on three Node versions. The matrix starts three independent jobs, and
since runner.os and the Node version go into the key computed by setup-node,
each one gets its own cache without overwriting the others.
name: Node.js CI
on: push: branches: [main] pull_request: branches: [main]
# No rights by default: the job asks for the minimumpermissions: {}
jobs: build: runs-on: ubuntu-24.04 permissions: contents: read
strategy: matrix: node-version: [18, 20, 22]
steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false
- name: Setup Node.js ${{ matrix.node-version }} uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: ${{ matrix.node-version }} cache: 'npm'
- name: Install dependencies run: npm ci
- name: Lint run: npm run lint
- name: Test run: npm test
- name: Build run: npm run buildA monorepo with workspaces
In a monorepo, setup-node only looks at the lockfile at the root by
default: a change inside a package therefore does not change the key, and the
restored cache is stale. cache-dependency-path fixes that behaviour by bringing
every lockfile of the repository into the key computation.
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20' cache: 'npm' cache-dependency-path: '**/package-lock.json' # Every lockfile
- run: npm ci --workspacesWith pnpm workspaces
pnpm does not need that setting: its single lockfile at the root already
describes every package of the workspace. The -r option then runs the script in
every package, honouring the order of their internal dependencies.
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: version: 9
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20' cache: 'pnpm'
- run: pnpm install --frozen-lockfile- run: pnpm -r build # Builds every packageThe advanced optimisations
A few particular cases, native modules and end-to-end tests, have their own caches worth knowing about.
Native packages (node-gyp)
For packages with native compilation (sharp, bcrypt and so on), the cost is not
the download but the compilation. node-gyp also downloads the Node kernel
headers into ~/.node-gyp: caching them avoids fetching them on every run. The
Node version goes into the key here, since a binary compiled for Node 20 is
unusable on Node 22.
- name: Cache native modules uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: | ~/.npm ~/.node-gyp key: native-${{ runner.os }}-node${{ matrix.node-version }}-${{ hashFiles('**/package-lock.json') }}Cypress
Cypress installs its browser, several hundred megabytes, outside node_modules,
into ~/.cache/Cypress: that folder therefore escapes the npm cache and has to
be declared separately.
- name: Cache Cypress uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: path: ~/.cache/Cypress key: cypress-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}The common mistakes
Two problems come back often with the Node.js cache. Here is how to recognise and fix them.
An invalid cache on npm ci
This message does not come from the cache itself but from a package.json and a
package-lock.json that are out of sync. A cache key that ignores the
lockfile prolongs the problem: the workflow keeps restoring an obsolete store
and the error survives every rerun.
npm ERR! `npm ci` can only install packages when your package.json and package-lock.json are in syncThe cache key has to include the hash of the lockfile:
# ✅ Correctkey: npm-${{ hashFiles('**/package-lock.json') }}pnpm: store not found
The symptom is a setup-node step failing and announcing that it cannot find the
pnpm store. The cause is always the same: setup-node runs before pnpm is
installed and therefore has no command to query to locate the store.
# Make sure pnpm is installed before setup-node- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: cache: 'pnpm'Key points
- The built-in cache of
setup-node(cache: 'npm'/'pnpm'/'yarn') covers most projects. - For pnpm, install
pnpm/action-setupbeforesetup-node, otherwise the store stays undiscoverable. - Caching
node_modulesdirectly is faster but fragile: thepostinstallscripts are skipped on a cache hit. - Cache the builds (
.next/cache,.turbo,.eslintcache) on top of the dependencies for the maximum gain. - In a monorepo,
cache-dependency-path: '**/package-lock.json'covers every workspace.
Next steps
- Concurrency: cancelling the obsolete runs so you do not pay twice for restoring the same npm cache.
- GitHub Actions runners: understanding where the cache is restored, and why changing runner distorts your measurements.
- GitHub CLI (gh): listing and purging the caches of a repository without going through the web interface.