How to cache dependencies in GitHub Actions (npm, pnpm, yarn)

Stop re-downloading node_modules on every run: the one-line setup-node cache, pnpm and yarn setups, actions/cache for everything else, and when not to cache.

Without a cache, every CI run downloads your whole dependency tree from the registry again. On a typical JavaScript project that is tens of seconds to a few minutes per job, on every push. In our scan of popular JS/TS repos, 41 of 121 (33.9%) had at least one CI workflow installing dependencies with no cache.

npm: one line in setup-node

actions/setup-node has a built-in cache. Add cache: npm and it saves and restores the npm cache directory, keyed on package-lock.json:

- uses: actions/checkout@v7
- uses: actions/setup-node@v7
  with:
    node-version: lts/*
    cache: npm
- run: npm ci

This caches the download cache (~/.npm), not node_modules. That is deliberate: npm ci still does a clean install, it just reads packages from disk instead of the network.

Since setup-node v5, npm projects get this cache automatically when package.json has a packageManager field set to npm (for example "packageManager": "npm@11.6.0"). If you rely on that, there is nothing to add. To turn it off on purpose, set package-manager-cache: false.

pnpm

pnpm has to be installed before setup-node runs, otherwise setup-node can't find the pnpm store to cache:

- uses: actions/checkout@v7
- uses: pnpm/action-setup@v6   # reads the version from "packageManager"
- uses: actions/setup-node@v7
  with:
    node-version: lts/*
    cache: pnpm
- run: pnpm install --frozen-lockfile

Yarn

- uses: actions/checkout@v7
- run: corepack enable          # Yarn 2+ (Berry); skip for Yarn 1
- uses: actions/setup-node@v7
  with:
    node-version: lts/*
    cache: yarn
- run: yarn install --immutable  # Yarn 1: yarn install --frozen-lockfile

Anything else: actions/cache

For build outputs and tool caches (Next.js .next/cache, Turborepo, Playwright browsers), use actions/cache with a key that changes when the inputs change:

- uses: actions/cache@v6
  with:
    path: .next/cache
    key: nextjs-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('src/**') }}
    restore-keys: |
      nextjs-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}-

restore-keys lets a run start from the closest older cache when there is no exact match, which is what makes build caches useful at all.

How the cache behaves

When not to cache

Release and publish workflows are a reasonable exception. A poisoned cache can end up inside the artifact you ship, so many projects install from scratch there on purpose. Our scanner does not flag release, publish or deploy workflows for this reason.

More guides