Speed up npm install GitHub Actions jobs: what each change is worth
The largest single way to speed up npm install GitHub Actions jobs is to stop running npm install at all: on our fixture, npm ci with a warm cache took 2.3 seconds and npm install with no lockfile took 13.3. Everything after that is smaller, and this page gives each change the number it is actually worth rather than a ranking somebody assumed.

The advice on this subject is almost entirely correct and almost entirely unranked. Use npm ci, cache the npm directory, skip the audit, try pnpm: all four are real, and three of them are worth well under a second on a normal project while the fourth is worth ten.
So we ran them. One lockfile with 414 packages, one runner, one job, node_modules emptied before every row, so each row installs exactly the same tree and differs only in the thing its label names. The results reorder the usual list.
Every change on one table
Read this top to bottom before changing anything. The first row is the mistake to stop making, the middle rows are what a cache and a couple of flags return, and the last two are what a different package manager does on the same dependency set.
Two results are worth noticing straight away. npm install with a lockfile present is not meaningfully slower than npm ci; the 13.3 seconds belongs to the case with no lockfile at all, where npm resolves the whole tree against the registry. And --prefer-offline, which every list recommends, moved nothing here, because with a warm cache npm was not making the requests it would have avoided.
Command, control held: same lockfile, same runner, node_modules emptied first | Duration |
|---|---|
npm install, no lockfile in the repository | 13.3 s |
npm ci, ~/.npm emptied | 4.1 s |
npm ci, with audit and funding left on | 2.9 s |
npm install, lockfile in place | 2.5 s |
npm ci --prefer-offline --no-audit --no-fund | 2.3 s |
npm ci --no-audit --no-fund | 2.3 s |
npm ci --omit=dev --no-audit --no-fund | 1.1 s |
yarn install --frozen-lockfile, cache emptied then restored | 7.0 s then 2.6 s |
pnpm install --frozen-lockfile, store emptied then restored | 1.0 s then 0.3 s |
The lockfile is the whole game
A lockfile turns installation from a resolution problem into a download problem. With one present, npm knows every version and every integrity hash before it starts and can fetch in parallel; without one it has to ask the registry what satisfies each range, then what satisfies each of those ranges, and so on down the tree. That is the 13.3 seconds against 2.5, on identical dependencies, on the same machine.
Worth recording from the same run: the lockfile npm install generated with nothing to go on was byte-identical to the committed one. So the resolution work was not producing a different answer, it was producing the same answer slowly, and that is the clearest argument for committing the file there is.
Prefer npm ci anyway, for a reason that is not speed. The docs are explicit: it "will never write to package.json or any of the package-locks", it removes an existing node_modules before it begins, and if the two files disagree it exits with an error instead of quietly updating the lock. A CI job that silently rewrites the lockfile is a CI job that passes on a tree nobody reviewed.
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '20'
cache: npm
- run: npm ci --no-audit --no-fundWhat the cache returns, and what the flags return
Emptying ~/.npm cost 1.8 seconds on this project, from 2.3 seconds warm to 4.1 cold. The cache entry was 20 MB and the installed tree it helps produce was 85 MB across 7,341 files. That is a real saving and a modest one, and it scales with the tree and with anything that compiles during install, so a project with native modules sees a much larger gap than this one.
The flags are smaller still and one of them is free money. Leaving audit and funding on cost 0.56 seconds, because both make registry requests your build does not need; --no-audit --no-fund removes them and nothing else. Run the audit on a schedule where a failure is actionable instead of on the path of every pull request. --prefer-offline came back identical to the plain command, which is what should happen when the cache already has what the lockfile asks for.
The biggest npm-only change on the table is the one nobody mentions: --omit=dev took the install to 1.1 seconds and the tree to 13 MB across 1,904 files. That is only available to a job that does not need the dev dependencies, so it is wrong for your test job and right for the job that builds a production image, and it is worth splitting a workflow to get.
What ~/.npm and --omit=dev are worth | Duration | Installed tree |
|---|---|---|
npm ci, cache emptied | 4.1 s | 85 MB, 7,341 files |
npm ci, cache restored | 2.3 s | 85 MB, 7,341 files |
npm ci --omit=dev, cache restored | 1.1 s | 13 MB, 1,904 files |
Size of the ~/.npm cache entry | 20 MB |
Cache the package manager directory, never node_modules
The tempting version of this is to cache node_modules itself and skip the install on a hit. It is faster and it is how you get failures that only exist in CI. That directory holds platform-specific binaries and compiled native modules, and a key that matches when the platform does not gives you a tree built for a different machine, with no error until something calls into it.
Cache ~/.npm and keep npm ci after the restore. The install reconciles the cache against the lockfile, so a stale or partial restore costs you a few seconds rather than an afternoon. This is also what setup-node does for you when you set cache: npm, which is the reason to use the option instead of writing the cache step yourself.
# what setup-node's cache: npm does, written out, for a job that needs the control
- uses: actions/cache@v6
id: npm
with:
path: ~/.npm
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-
# 'true' = exact key hit, 'false' = a restore-key hit, '' = nothing matched
- run: echo "cache-hit: '${{ steps.npm.outputs.cache-hit }}'"
- run: npm ci --no-audit --no-fundpnpm and yarn on the same dependency set
We converted the same lockfile for both and installed it the same way. pnpm was the fastest thing on the page by a distance: 1.0 seconds with its store emptied and 0.3 seconds with it restored, because it hard-links from a content-addressable store instead of copying a tree. Yarn 1 was the slowest: 7.0 seconds cold and 2.6 warm.
Before you migrate on those numbers, two things. The pnpm cold row is flattered by this runner's network, which pulled 81 MB into the store in about a second, so on a slower link the gap narrows. And the store is 81 MB against the npm cache's 20 MB, which is four times as much of your 10 GB repository cache budget for every matrix leg. pnpm is genuinely faster here; it is not free.
| Same dependencies, three package managers | Store or cache emptied | Restored | Size of what is cached |
|---|---|---|---|
pnpm install --frozen-lockfile | 1.0 s | 0.3 s | 81 MB |
npm ci --no-audit --no-fund | 4.1 s | 2.3 s | 20 MB |
yarn install --frozen-lockfile | 7.0 s | 2.6 s | 104 MB |
What we ran, so you can disagree with it
One script, job-i.sh, run once on a Latchkey latchkey-small runner on 20 September 2026, committed under content/repro/timings/speed-up-npm-install-in-github-actions/ with the script, the unedited job-i.log and a job-i.status.json naming the job id, runner size and exit code committed beside each other.
The fixture is deliberately the same package.json that job-a.sh used for the pillar page, which is why the two npm rows reproduce to within a tenth of a second across two separate jobs on two separate machines. That is the only repeat measurement here; every other row is a single pass and carries runner-level noise, so a gap under about half a second, such as --prefer-offline against the plain command, is reported as no difference rather than as a small one.
The honest limits: 414 packages is a modest tree with no native compilation, so the cache saving is at the small end of what a real project sees, and the runner sits close to the registry, which biases every cold row downward. The ratios are the part that transfers.
Frequently asked questions
Why is npm install taking 10 minutes in GitHub Actions?
package-lock.json committed, npm resolves the entire dependency graph against the registry before it downloads anything, which took 13.3 seconds against 2.5 on a 414-package project here and scales badly with tree size. After that, check that the cache step is actually hitting, and whether any dependency is compiling a native module on the runner.Should I cache node_modules or ~/.npm?
~/.npm, and keep npm ci after the restore. The package manager directory is reconciled against your lockfile by the install, so a stale entry costs seconds. A cached node_modules is a tree of platform-specific binaries restored on a key that cannot see the platform, which is how you get a failure that reproduces nowhere but CI.Why does npm ci fail saying package.json and package-lock.json are not in sync?
package.json without running an install, so the lockfile no longer describes it. npm ci refuses rather than fixing it quietly, which is the behavior you want on CI. Run npm install locally, commit the updated lockfile, and keep npm ci in the workflow.Do I still need actions/cache if I use setup-node with cache: npm?
~/.npm for you with the lockfile hashed into the key, which is the same thing a hand-written step would do. Write the step yourself only when you need a key the action will not produce, such as one that includes a matrix dimension, and then do not also set the option, because two writers for one path is a restore nobody can reason about.