Skip to content
Français
CI/CD & Automatisation medium

Node.js and npm caching in GitHub Actions

30 min de lecture

Read this page in French

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-node for npm, pnpm and yarn
  • Configure actions/cache when you need control
  • Cache the builds of Next.js, Turborepo and ESLint
  • Handle a monorepo with workspaces
  • Avoid the traps of the node_modules cache 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 ci

Automatic 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 presentManager detected
package-lock.jsonnpm
pnpm-lock.yamlpnpm
yarn.lockyarn
# 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-lockfile

Manual 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 ci

pnpm 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-lockfile

yarn 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 --immutable

Caching 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 ci

Careful

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 -- --cache

A 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 minimum
permissions: {}
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 build

A 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 --workspaces

With 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 package

The 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.

Fenêtre de terminal
npm ERR! `npm ci` can only install packages when your package.json and package-lock.json are in sync

The cache key has to include the hash of the lockfile:

# ✅ Correct
key: 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-setup before setup-node, otherwise the store stays undiscoverable.
  • Caching node_modules directly is faster but fragile: the postinstall scripts 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.

Is this site useful to you?

Fewer than 1% of readers support this site.

I maintain more than 700 free guides, with no ads and no tracking. Any support, even a symbolic one, helps cover hosting and keeps these resources free. Thank you for the help.

The form does not show? Open Ko-fi in a new tab.

Subscribe and follow my DevSecOps work on LinkedIn