---
title: "Optimize npm Installs in GitHub Actions | StarSling"
description: "Enable setup-node's built-in npm cache, cache large downloads, and drop tool installs a job never uses."
url: https://starsling.dev/github-actions/optimizations/optimize-npm-install
canonicalUrl: https://starsling.dev/github-actions/optimizations/optimize-npm-install
---

# Optimize npm installs in GitHub Actions

[GitHub Actions](https://starsling.dev/github-actions) / [Optimizations](https://starsling.dev/github-actions/optimizations) / Optimize npm installs in GitHub Actions

- [How StarSling works](https://starsling.dev/)

Rule: ci.cache.npm-install. Detection mode: static. Last updated: 2026-08-21

A fast npm install in GitHub Actions restores the npm cache through actions/setup-node's own cache input, caches large secondary downloads like browser binaries separately, and skips installing tools a job never runs; skipping any of the three means npm ci and its trailing install steps redo work from zero on every run.

## Table of contents

- [Do this](#do-this)
- [Avoid this](#avoid-this)
- [How to detect it](#how-to-detect-it)
- [Tradeoffs and safety](#tradeoffs)
- [Turn on setup-node's own cache](#setup-node-cache)
- [Cache downloads npm's own cache does not cover](#cache-secondary-downloads)
- [Skip installs a job never uses](#skip-unused-installs)
- [Verify it worked](#verify)
- [Related pages](#related-pages)
- [Sources](#sources)

<a id="do-this"></a>

## Do this

actions/setup-node's cache input is the single biggest lever here: turn it on and the action wraps actions/cache around npm's local cache directory automatically, keyed on your lockfile, so npm ci restores previously-downloaded packages instead of refetching every tarball from the registry. That cache only covers npm's own package cache, though. A step like `npx playwright install` downloads browser binaries through a completely separate path, so it still refetches every run unless it is cached on its own key. And a job that runs an install step for a tool it never invokes, a unit-test job installing Playwright browsers it never launches, is pure waste layered on top: time spent downloading something no later step in that job touches. In one matrix of six vitest jobs that each installed Playwright browsers despite only running unit tests, removing the unused install step alone saved roughly 270 seconds of total runtime across the matrix.

_.github/workflows/ci.yml_

```yaml
name: CI
on: [pull_request]

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run test:unit
      # no Playwright install here: this job never launches a browser

  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - name: Cache Playwright browsers
        uses: actions/cache@v4
        with:
          path: ~/.cache/ms-playwright
          key: playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
      - run: npx playwright install --with-deps chromium
      - run: npm run test:e2e
```

<a id="avoid-this"></a>

## Avoid this

_.github/workflows/ci.yml_

```yaml
name: CI
on: [pull_request]

jobs:
  unit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm install
      - run: npx playwright install --with-deps chromium
      - run: npm run test:unit

  e2e:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm install
      - run: npx playwright install --with-deps chromium
      - run: npm run test:e2e
```

<a id="how-to-detect-it"></a>

## How to detect it

1. Check every actions/setup-node step for a `cache` input: `grep -A5 'actions/setup-node' .github/workflows/*.yml` - a step with no `cache: npm` (or `cache: 'npm'`) restores nothing between runs.
2. Search for browser or SDK installs that are never cached: `grep -n 'playwright install' .github/workflows/*.yml`, then check whether an `actions/cache` step with a matching path (for example `~/.cache/ms-playwright`) appears earlier in the same job.
3. Search for tool installs that outlive their purpose: for each `playwright install` (or similar) hit, check whether that same job later runs `playwright test` or imports `@playwright/test`; if it only runs `vitest`, `jest`, or another unit-test runner, the install is unused.
4. Search for `npm install` where `npm ci` would do: `grep -rn 'npm install' .github/workflows/*.yml` in jobs that only need to reproduce an existing `package-lock.json`, not change it.

<a id="tradeoffs"></a>

## Tradeoffs and safety

- actions/setup-node's cache input needs a lockfile to key on; a repository with no package-lock.json (or one that is gitignored) gets no cache and no error, so confirm the lockfile is committed before relying on it.
- npm ci is not a drop-in swap for npm install: it deletes node_modules first and fails if package-lock.json is missing or out of sync with package.json. That failure is the point in CI (it catches an unlocked dependency change) but it will break a workflow that was relying on npm install to quietly reconcile a stale lockfile.
- Keying the Playwright browser cache on package-lock.json works because the exact @playwright/test version is locked there; if a repository pins Playwright some other way (a Dockerfile ARG, an environment variable), key the cache on that value instead so an upgrade actually busts the cache.
- Before deleting a tool-install step, confirm no other step in the same job depends on it indirectly, for example a global CLI installed once and reused by a later script. Removing an install that looks unused from the job's own test command can still break a step that shells out to it.

<a id="setup-node-cache"></a>

## Turn on setup-node's own cache

actions/setup-node's cache input is not actions/cache configured for you behind the scenes for convenience; it is the single highest-impact change available here, because most npm-based workflows already call setup-node and are one line away from using it. Set cache to npm and the action hashes your lockfile, restores npm's package cache when that hash matches a prior run, and saves it again afterward, all without a separate cache step to write and maintain.

_.github/workflows/ci.yml_

```yaml
- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: npm
```

<a id="cache-secondary-downloads"></a>

## Cache downloads npm's own cache does not cover

npm's cache only holds packages it installs through the registry. A postinstall step or separate CLI call that fetches something else, browser binaries, a compiler toolchain, an SDK, is invisible to it and refetches on every run unless you cache that path yourself with actions/cache, keyed on whatever value determines the download's version (the lockfile, a Dockerfile ARG, an explicit version pin).

<a id="skip-unused-installs"></a>

## Skip installs a job never uses

The fastest install step is the one that does not run. A unit-test job that installs Playwright browsers it never launches, or a lint job that installs a database client it never connects to, pays the download cost of a tool with no later step to justify it. Audit each job's install steps against what that job's own commands actually invoke, and remove what does not match.

<a id="verify"></a>

## Verify it worked

Hand this prompt to your coding agent (Claude Code, Cursor, and the like) to run this audit and open the fix as a reviewable PR:

```
Audit this repository's npm install steps in GitHub Actions and speed them up.

1. grep .github/workflows/ for every actions/setup-node step. For each one that installs
   npm dependencies afterward, check whether it sets `cache: npm` (or `cache: 'npm'`); if
   not, add it, and confirm a committed package-lock.json exists for it to key on.
2. Where a job uses `npm install` only to reproduce an existing lockfile (not to change
   it), switch it to `npm ci`. Do not make this change in a job whose purpose is updating
   the lockfile itself.
3. Find tool-install steps (for example `npx playwright install`) and check two things per
   job: (a) does this same job later run that tool, and if not, remove the install step;
   (b) if it does run the tool, is the download cached with actions/cache keyed on the
   lockfile hash or an equivalent version pin, and if not, add that cache step before the
   install.
4. Read the actions/setup-node, npm ci, and Playwright CI caching docs linked on this page
   before editing.
5. Show the full diff and open a pull request; do not apply changes blindly. In the PR
   body, list each change with file and job name, and state how to verify: re-run the same
   commit and confirm the setup-node and any tool-cache steps report a cache hit.
```

Confirm the change landed:

1. Re-run the workflow on an unchanged commit and check the setup-node step's log: it should read a cache restore (for example "Cache restored from key") instead of "Cache not found for input keys".
2. For the e2e job, re-run on the same commit and confirm the Playwright browser cache step reports a hit and the subsequent `playwright install` step completes without downloading browser binaries.
3. Compare each job's total step duration before and after: the unit job should drop by however long the removed browser install took, and both jobs' install steps should shrink once the npm cache is warm.

<a id="related-pages"></a>

## Related pages

- [GitHub Actions cache: dependencies, keys, and cache hits](https://starsling.dev/best-practices/github-actions/cache-dependencies)
- [Optimize pnpm installs in GitHub Actions](https://starsling.dev/github-actions/optimizations/optimize-pnpm-install)
- [Stop reinstalling the same dependencies in GitHub Actions](https://starsling.dev/github-actions/optimizations/avoid-duplicate-dependency-installs)

<a id="sources"></a>

## Sources

- [actions/setup-node: caching global packages data](https://github.com/actions/setup-node#caching-global-packages-data)
- [npm docs: npm ci](https://docs.npmjs.com/cli/commands/npm-ci)
- [GitHub Actions: caching dependencies to speed up workflows](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows)
- [Playwright: caching browsers in CI](https://playwright.dev/docs/ci#caching-browsers)
