Optimize npm installs in GitHub Actions

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.

ci.cache.npm-installstatic · checkable from the repo
How StarSling works

AI agents open the PR

StarSling agents run this exact audit on your workflows, apply the fix, and open a reviewable PR automatically.

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

Avoid this

.github/workflows/ci.yml
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

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.

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.

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
- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: npm

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

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.

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.

Prompt for your coding agent
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.

Go further

One fix, all of them, or forever.

You have the prompt for this one practice. Here is how much further you can take it, each step doing more for you than the last.

  1. Fix this one thing

    Copy the prompt above

    Hand OPT1 / OPT2 / OPT9 to your coding agent and fix it in your repo today.

  2. Fix everything, once

    Install the ci-speedup skill

    One prompt audits your whole repo against all 73 ci-speedup patterns (these 3 plus 70 more) and hands your agent every fix at once. Open source, MIT, runs locally.

  3. Keep it fixed, forever

    Install the StarSling GitHub App

    Connect GitHub and the fixes stay applied as your CI evolves, with agents that keep inspecting your workflows and opening optimization PRs you review.

Sources

1actions/setup-node: caching global packages data (opens in new tab)

2npm docs: npm ci (opens in new tab)

3GitHub Actions: caching dependencies to speed up workflows (opens in new tab)

4Playwright: caching browsers in CI (opens in new tab)

Last updated 2026-08-21