Why Is npm install Slow in GitHub Actions?

Most of a cold npm install is registry fetches plus native module compilation. A dependency cache removes the fetches, and only more vCPU touches the builds.

Last verified:

Most of a cold npm install in GitHub Actions is network work followed by native module compilation: metadata requests to the registry, tarball downloads, extraction of tens of thousands of small files, and then a node-gyp build for every package that ships no prebuilt binary for the runner platform. A dependency cache removes the download half of that list and leaves the compilation half untouched, which is why teams who turn caching on and still watch a slow install step are usually looking at compiler time rather than bandwidth.

Answer

Four stages run inside one install step, and each one has a different lever.

StageWhat the runner is doingRemoved by a warm dependency cache
Metadata resolutionAsking the registry which versions satisfy the ranges in package.jsonSkipped entirely by npm ci, which reads the lockfile instead
Tarball downloadsFetching one packed tarball per package over the networkYes, the fetches become local reads from ~/.npm
Extraction and linkingUnpacking those tarballs into node_modules, one small file at a timeNo
Lifecycle scripts and native buildsRunning install and postinstall scripts, including node-gyp compilesNo

npm ci covers the first row by contract. It installs a project "exactly as described" by the lockfile, deletes node_modules before it starts, and errors out when the lockfile and package.json disagree instead of resolving the difference (npm ci). No resolver pass means no version negotiation with the registry.

The second row is what a cache buys you. npm stores every packed tarball it has downloaded in the cacache directory under ~/.npm on Linux and macOS (npm cache), and --prefer-offline tells npm to skip the staleness checks and go to the network only for entries the cache does not hold (config reference). Restore that directory at the start of a job and the download stage becomes a local copy.

Here is the whole configuration on a WarpBuild runner:

name: test
on:
  pull_request:

jobs:
  unit-tests:
    runs-on: warp-ubuntu-latest-x64-4x
    steps:
      - uses: actions/checkout@v4
      - uses: WarpBuilds/setup-node@v6
        with:
          node-version: 22
          cache: npm
          cache-dependency-path: package-lock.json
      - run: npm ci --prefer-offline --no-audit --fund=false
      - run: npm test

WarpBuilds/setup-node accepts the same inputs as the upstream action and routes the cache through WarpBuild Cache when it runs on a WarpBuild runner, with no other workflow edit required (setup actions documentation). The two flags after --prefer-offline remove work that has nothing to do with dependencies: --no-audit drops the audit request npm sends to the registry after every install, and --fund=false drops the funding lookup (config reference).

Detail

Reading the breakdown on your own repository

npm ci --timing writes a timing file into the npm logs directory alongside the debug log (config reference), and --foreground-scripts prints the output of install scripts as they run (config reference) so the package that is compiling names itself in the log.

Take a cold run that lands at 140 seconds, split the way a mid-sized dependency tree with two or three native addons usually splits. These durations are illustrative, and the point is the shape rather than the totals; replace them with your own timing output before acting on any of it.

StageCold runAfter a warm cache
Metadata resolution8s0s with npm ci on both runs
Tarball downloads55sabout 3s of local reads
Extraction and linking32s32s
Lifecycle and native builds45s45s
Total140sabout 80s

The cache removed 60 seconds and left 80. That residual is the number worth arguing about, because no cache key change moves it.

Why the compilation half survives the cache

A package with native code ships either a prebuilt binary for your platform or C++ sources that node-gyp compiles at install time (node-gyp). The registry cache holds the tarball, so the download is gone on the second run, and the compile inside that tarball runs again because its output lives in node_modules rather than in the store.

Prebuilt coverage is per platform. A package that publishes a prebuilt for linux-x64 and nothing for linux-arm64 downloads on one label and compiles on the other, from the same lockfile. That difference shows up as a matrix leg that takes minutes longer than its sibling with no other explanation. The full label list and shapes are in the cloud runners documentation.

Caching the node_modules directory outright is the tempting shortcut here, and it carries its own failure mode, since the tree holds absolute paths and platform-specific binaries. How to cache node_modules in GitHub Actions walks through when the directory cache is worth the risk and how to key it.

When a bigger runner is the right lever

Compilation is CPU bound, and it is the only stage in the table that responds to more cores. npm runs lifecycle scripts for independent packages concurrently, so a tree with several compiling addons uses several cores, while a tree whose install time sits inside one large addon does not. Registry waits and single-file extraction gain nothing from a size change.

Doubling the runner size doubles the per-minute rate, so the arithmetic is exact. Take a job that bills 6 minutes on warp-ubuntu-latest-x64-4x at $0.008 per minute, or $0.048 per run. The same 6 minutes on warp-ubuntu-latest-x64-8x at $0.016 per minute is $0.096, so the move lowers the bill only if the job drops below 3 minutes. With 80 seconds of install in play, and only 45 of those seconds compiling, it cannot. Buy the bigger size for wall clock when the compile share is large, and check that share before you buy it.

Rates from the pricing page and the cloud runners documentation, with GitHub list prices from the GitHub Actions minute multipliers reference, checked on 2026-08-13:

ShapeWarpBuild labelWarpBuild per minuteGitHub-hosted equivalentGitHub per minute
4 vCPU, 16 GBwarp-ubuntu-latest-x64-4x$0.0084-core Linux larger runner$0.012
8 vCPU, 32 GBwarp-ubuntu-latest-x64-8x$0.0168-core Linux larger runner$0.022
16 vCPU, 64 GBwarp-ubuntu-latest-x64-16x$0.03216-core Linux larger runner$0.042

warp-ubuntu-latest-x64-8x (8 vCPU, 32 GB) costs $0.016 per minute against $0.022 per minute for the 8-core Linux larger runner (8 vCPU, 32 GB): 27 percent lower list price, GitHub list price checked on 2026-08-13. Every cost claim on this page carries a number, a source link, and a checked-on date, and the same numbers appear on every WarpBuild surface. Sizing runners for Node test suites covers the same break-even for the test step, which usually parallelizes better than the install does.

When compilation is unavoidable

Some trees compile on every run whatever you do, because the addon has no prebuilt for the platform and the build is the product. Snapshot runners boot a job from a runner VM captured mid workflow, so a compiled tree can already be on disk when the job starts, and the observability reports are where the install step's real duration and its share of the job come from before any of this is worth changing. Node.js builds on WarpBuild GitHub Actions runners puts the install step back in the context of the whole pipeline.

Is npm ci faster than npm install in GitHub Actions?

Yes, for a workflow. npm ci reads package-lock.json and installs exactly what it describes, so the resolver never runs and no registry round trip is spent working out which versions satisfy the ranges in package.json (npm ci). It also deletes node_modules before it starts, which makes every run identical and rules out the stale-tree failures that npm install can produce.

Why is my install still slow when the cache hits?

Because the cache only removes the download stage. A warm npm cache turns registry fetches into local reads from ~/.npm, and the runner still extracts tens of thousands of small files and still compiles every native addon with no prebuilt binary for the platform. If the install step barely moves once the cache starts hitting, the remaining time is extraction and node-gyp. How to cache node_modules in GitHub Actions covers what the store cache does and does not hold.

Should I move to a bigger runner or change package managers?

Measure the compile share first with npm ci --timing. Native compilation responds to more vCPU while registry waits and extraction do not, and doubling the size doubles the per-minute rate, so the move lowers the bill only when the job finishes in under half the wall clock time it took before. Sizing runners for Node test suites works the same arithmetic through a full pipeline, and Node.js builds on WarpBuild GitHub Actions runners covers the labels to try.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.