BEGINNERGITHUB-ACTIONSOPTIMIZATION

Cache npm Dependencies in GitHub Actions

Cache the npm store between GitHub Actions runs to cut install time from minutes to seconds. Lockfile-aware cache key included.

Published May 16, 2026
github-actionscachenpmperformanceci-cd

GitHub Actions caching for npm dependencies turns a 60-second npm install into a 5-second cache restore. The simplest pattern is to use actions/setup-node's built-in cache option, which hashes your lockfile and stores the npm package store. For more control, use actions/cache directly.

Tested on GitHub Actions ubuntu-latest, Node 24, npm 11.

When to Use This

  • Every workflow that runs npm ci or npm install
  • Monorepos with multiple lockfiles (each gets its own cache)
  • Workflows that run on every PR push (cache hits dominate)
  • Self-hosted runners where you want persistent local caching

Don't use this when the install is already fast enough that the cache restore would be slower than a fresh install (rare — usually only for tiny projects).

Code

The simplest version — actions/setup-node handles everything:

- name: Setup Node
  uses: actions/setup-node@v4
  with:
    node-version: '24'
    cache: 'npm' # or 'pnpm', 'yarn'
 
- name: Install dependencies
  run: npm ci

The cache: 'npm' option:

  1. Hashes your package-lock.json to create a cache key
  2. Restores the npm cache from previous runs if the key matches
  3. Saves the cache automatically at the end of the job

For monorepos with multiple lockfiles, point at the specific one:

- name: Setup Node
  uses: actions/setup-node@v4
  with:
    node-version: '24'
    cache: 'npm'
    cache-dependency-path: |
      apps/web/package-lock.json
      packages/ui/package-lock.json

Usage

When you need finer control (uncommon, but useful), use actions/cache directly:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-node@v4
        with:
          node-version: '24'
 
      - name: Get npm cache directory
        id: npm-cache
        run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
 
      - name: Restore npm cache
        uses: actions/cache@v4
        with:
          path: ${{ steps.npm-cache.outputs.dir }}
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-
 
      - run: npm ci
      - run: npm run build

The restore-keys block provides a fallback: if the exact lockfile hash isn't cached, fall back to any cache from the same OS. This gives you a partial speedup when the lockfile changes by only a few packages.

Pitfalls

  • npm ci is required, not npm install. ci enforces the lockfile and is faster. install mutates the lockfile, which breaks reproducibility.
  • Don't cache node_modules directly. It contains compiled binaries that may not work across Node versions or runners. Cache the npm store instead and re-run npm ci.
  • Cache size limits matter. GitHub Actions caches are capped at 10 GB per repo (5 GB on free plans for old caches). Old entries are evicted automatically by least-recently-used.
  • hashFiles paths are relative to the repo root. Globs work: **/package-lock.json catches every lockfile in the repo.
  • Cache hits are not retroactive. You only see cache hits after the first run that creates the cache. The first PR after adding caching is slow.
  • actions/setup-node@v4 cache only saves on default branch by default. This prevents PR caches from polluting the main cache. Override with save-always: true if you want PR caches saved too.

Frequently Asked Questions

Should you cache node_modules or the npm cache?

Cache the npm cache (the global package store), not node_modules. node_modules is platform-specific, contains binary builds, and gets stale fast. The npm store is platform-shared, content-addressed, and always reproducible by re-running npm ci. actions/setup-node uses this approach by default.

What's the right cache key for npm dependencies?

Hash the lockfile (package-lock.json or pnpm-lock.yaml). When the lockfile changes, the cache key changes, and you get a fresh cache. When the lockfile is unchanged, you get a cache hit. actions/setup-node does this automatically when you set the cache option.

X (Twitter)LinkedIn