LEARN · DEBUGGING GUIDE

npm ci Slowing Down Your CI Pipeline? Here's What to Check

npm ci should be fast. When it's not, it's usually a cache issue, a lockfile mismatch, or a registry problem. Here's how to diagnose and fix each one.

IntermediateCI/CD7 min read

What this usually means

npm ci is designed to be deterministic and fast—it skips dependency resolution and installs exactly what's in package-lock.json. When it's slow, the most common cause is that the npm cache is cold (not persisted or restored correctly), forcing a full download from the registry. Another frequent issue is lockfile drift: if package-lock.json is not checked in or is corrupted, npm ci will fail or fall back to a full resolution. Network latency to the registry (especially if you're using a default registry far from your CI region) can also dominate. Less obvious but equally painful: npm ci re-evaluates lifecycle scripts (like postinstall) even if nothing changed, and if those scripts are slow or hang, the whole step stalls. I've also seen CI pipelines where the npm cache key is wrong, so Docker layers or cloud caches are restored but contain the wrong version, forcing a fresh download.

( 01 )Fast diagnosis

The first ten minutes — establish facts before touching code.

  • 1Run `npm cache ls` or check `npm cache verify` on a CI runner to see if the cache has any content
  • 2Compare the size of your node_modules before and after npm ci: if it's always downloading from scratch, cache is likely missing
  • 3Check the `npm ci` output for `sill idealTree` or `sill fetch` lines that hang for >30 seconds
  • 4Run `npm ci --prefer-offline` locally with the same lockfile to see if it uses cache
  • 5Inspect your CI cache key: does it include the lockfile hash? If not, cache may be stale
  • 6Check network latency: `curl -w '%{time_total}' https://registry.npmjs.org/` from your CI runner
( 02 )Where to look

The specific files, logs, configs, and dashboards that usually own this bug.

  • searchCI pipeline YAML (cache section for npm cache keys and paths)
  • searchpackage-lock.json (check if it's up-to-date with package.json)
  • searchnpm-debug.log or npm ci verbose output (`npm ci --loglevel verbose`)
  • searchCI runner's npm cache directory (usually `~/.npm` or `$(npm config get cache)`)
  • search.npmrc file (for registry URLs and auth tokens)
  • searchCloud cache dashboard (e.g., GitHub Actions cache, CircleCI cache, S3 bucket)
  • searchCI step timing breakdown (e.g., 'restore cache', 'npm ci', 'save cache')
( 03 )Common root causes

Practical causes, not theory. These are the things you will actually find.

  • warningnpm cache is not persisted or restored across CI runs
  • warningCache key does not include the lockfile hash, so cache is reused incorrectly
  • warningpackage-lock.json is not committed or is out of sync with package.json
  • warningRegistry is slow or far from CI region (default npm registry may be throttled)
  • warningLifecycle scripts (postinstall, prepare) are slow or hang
  • warningLarge number of transitive dependencies due to a package with many dependencies
  • warningnpm ci runs without the `--no-audit` and `--no-fund` flags, which add extra network calls
( 04 )Fix patterns

Concrete fix directions. Pick the one that matches your root cause.

  • buildPersist the npm cache directory (`~/.npm`) in CI and key it on the lockfile hash (e.g., `npm-cache-{{ checksum 'package-lock.json' }}`)
  • buildAdd `--no-audit --no-fund` flags to `npm ci` to skip unnecessary network requests
  • buildUse a registry mirror or a private npm proxy (e.g., Verdaccio) closer to your CI region
  • buildEnsure package-lock.json is committed and up-to-date: run `npm install` locally and commit the lockfile
  • buildDisable or reduce lifecycle scripts: use `--ignore-scripts` if possible, or move heavy scripts to separate steps
  • buildIf using Docker, copy package-lock.json before package.json to maximize layer caching
  • buildSet `prefer-offline=true` in .npmrc to use cache aggressively
( 05 )How to verify

A fix you cannot prove is a guess. Close the loop.

  • verifiedRun npm ci twice in the same CI runner: second run should be <30s if cache works
  • verifiedCheck cache hit/miss logs in CI output (e.g., 'Cache restored from key ...' vs 'Cache not found')
  • verifiedCompare node_modules size: if cache is used, `npm ci` should not re-download packages
  • verifiedRun `npm ci --prefer-offline` locally with the same lockfile: should not contact the registry
  • verifiedMonitor CI step duration over a week: should remain stable within 10% variance
( 06 )Mistakes to avoid

Things that make this bug worse or harder to find.

  • warningUsing `npm install` instead of `npm ci` in CI (install resolves deps each time)
  • warningNot pinning the npm version in CI (different versions handle cache differently)
  • warningIncluding node_modules in the CI cache (can cause corruption; cache only npm cache directory)
  • warningUsing a cache key that doesn't change when lockfile changes (e.g., key only on branch name)
  • warningIgnoring npm audit or fund warnings (they slow down the install)
  • warningRunning `npm ci` without first restoring the cache (order matters)
( 07 )War story

The 12-Minute Install That Should Have Taken 30 Seconds

Senior Backend EngineerNode.js 16, npm 8, GitHub Actions, AWS S3 cache

Timeline

  1. 09:00Deploy pipeline triggered for a small README change
  2. 09:02Cache restore step completes: 'Cache restored from key npm-cache-main-abc123' (false hit)
  3. 09:03npm ci starts, log shows 'added 1450 packages in 420s' — way too long
  4. 09:10npm ci finally finishes, but the step took 7 minutes
  5. 09:12Tests run in 2 minutes, but total pipeline is 12 minutes
  6. 09:15I check the cache key: it was based on branch name, not lockfile hash
  7. 09:20Look at the cache directory: it's empty! The cache was stored but the path was wrong
  8. 09:25Fix the cache path to `~/.npm` and key to lockfile hash
  9. 09:30Rerun pipeline: cache hit, npm ci takes 28 seconds

Our deployment pipeline had been taking 12 minutes for weeks, and everyone assumed it was just the normal duration. It was a README-only change, so I expected a quick pipeline. But npm ci alone took 7 minutes. I've seen slow npm ci before, but this was absurd.

I checked the GitHub Actions logs. The cache restore step printed 'Cache restored from key npm-cache-main-abc123' — but the key was just the branch name, not the lockfile hash. Worse, the cache path was set to `./node_modules` instead of `~/.npm`. So the cache was being restored, but it contained nothing useful because npm ci doesn't use node_modules from cache directly. It was effectively a cache miss every time.

I updated the cache key to `npm-cache-{{ checksum 'package-lock.json' }}` and set the path to `~/.npm`. Also added `--no-audit --no-fund` to shave off another 30 seconds. Next run: npm ci took 28 seconds. The lesson: cache keys must be precise, and you must cache the right directory. Don't assume cache restore means cache hit.

Root cause

Cache key did not include lockfile hash and cache path pointed to node_modules instead of the npm cache directory, causing a cache miss every time.

The fix

Changed cache key to `npm-cache-{{ checksum 'package-lock.json' }}` and cache path to `~/.npm`. Also added `--no-audit --no-fund` to reduce network calls.

The lesson

Always verify cache hits are real. Cache restore message doesn't mean the cache contains what you need. Use precise keys and correct paths.

( 08 )How npm ci Uses Cache (and Why It's Often Broken)

npm ci is supposed to be fast because it reads package-lock.json and installs exactly what's there without resolving versions. But every package still needs to be downloaded from the registry if it's not in the npm cache. The npm cache lives at `~/.npm` by default (check with `npm config get cache`). When a package is downloaded, it's stored as a tarball in `~/.npm/_cacache/`. On subsequent runs, npm ci checks this cache first. If the cache is empty, it downloads everything.

The typical CI mistake is caching `node_modules` instead of the npm cache directory. `node_modules` is the result of the install, not the source of cached packages. npm ci doesn't reuse `node_modules` from a previous run because it's designed to be clean. So caching `node_modules` is useless and can cause corruption if the Node.js version changes. Always cache `~/.npm` (or the custom cache path) and key it on the lockfile hash.

( 09 )Lockfile Drift: The Silent Killer of Fast npm ci

If package-lock.json is not committed, or if it's out of sync with package.json, npm ci will fail with a validation error. But there's a subtler case: some developers regenerate the lockfile with a different npm version or OS, causing the lockfile to change even if package.json hasn't. This invalidates the CI cache key (since it's based on lockfile hash) and forces a full download.

The fix is to enforce lockfile integrity: commit it, run `npm install` (which updates the lockfile) in a pre-commit hook, and consider using `npm ci --prefer-offline` locally to catch drift early. Also, standardize the npm version across the team and CI to avoid lockfile format changes.

( 10 )Registry Latency and Mirrors

If your CI runners are in Europe but using the default registry (registry.npmjs.org, US-east), you can see 500ms latency per request. With 1000+ packages, that's 500 seconds of extra wait. Check with `curl -w '%{time_total}' https://registry.npmjs.org/` from your CI runner. If it's >200ms, consider a mirror.

Set up a private npm proxy like Verdaccio in the same region as your CI. Or use a CDN-backed registry like npmmirror.com (for China) or cloudsmith. In .npmrc, set `registry=https://your-proxy.com/`. Also, use `prefer-offline=true` to avoid hitting the registry if the package is cached.

( 11 )Lifecycle Scripts: Hidden Time Sinks

npm ci runs lifecycle scripts (postinstall, prepare, etc.) by default. If a package's postinstall script does something heavy (like compiling native modules or downloading binaries), it can hang or take minutes. I've seen `node-sass` postinstall take 2 minutes because it tried to download a binary from a slow CDN.

To diagnose, run `npm ci --ignore-scripts` and see if the install becomes fast. If so, you need to handle those scripts: either skip them (if you don't need them), or pre-build them in a Docker image. Also check for packages that run `npx` in postinstall (e.g., `husky`), which adds another dependency download.

Frequently asked questions

Why does npm ci take longer than npm install sometimes?

npm ci deletes node_modules and installs from scratch based on the lockfile. npm install may reuse existing node_modules and update selectively. If the lockfile is large, npm ci's strictness can be slower than a partial install. But in CI, npm ci is still preferred for consistency. To speed it up, use cache and flags like --no-audit.

My cache restores successfully but npm ci still downloads everything. What's wrong?

Possible reasons: (1) Cache path is wrong—npm ci uses ~/.npm, but you're caching node_modules. (2) Cache key is not based on lockfile, so cache contains old packages. (3) npm version mismatch—cache format changed between versions. Check the cache directory contents and ensure the key changes when dependencies change.

Does npm ci use the network if the package is already in node_modules?

npm ci always deletes node_modules first, so it doesn't rely on existing node_modules. It checks the npm cache (in ~/.npm). If the cache is empty, it downloads. That's why you must cache ~/.npm, not node_modules.

What does 'sill idealTree buildDeps' mean?

'sill' stands for 'silly' log level. idealTree is the internal dependency tree construction. If it hangs here, npm is resolving dependencies (even though npm ci shouldn't). This often indicates a lockfile mismatch or corruption. Run `npm ci --loglevel verbose` to see more details.

Should I use npm ci or npm install in Docker?

Use npm ci in Docker for reproducibility. But for Docker layer caching, copy package.json and package-lock.json first, then run npm ci. This way, the layer is cached as long as the lockfile doesn't change. Also, set `NODE_ENV=production` to skip devDependencies if possible.