# npm ERR! network errors in GitHub Actions

> Fix npm ERR! network in GitHub Actions by reading the code under it: which registry failures are worth a retry, and which will never pass.

Source: https://latchkey.dev/learn/failures/npm-network-errors-in-ci  
Updated: 2026-09-20

An `npm ERR! network` line in GitHub Actions means npm reached the registry and the request failed, not that anything is wrong with your dependency tree. Read the code printed under it: a 5xx, a throttle or an E-code is transient and worth retrying, while a 404 or an unsupported protocol will fail the same way every time.

## What this error means

The install step fails and the log is a block of lines that all carry the same prefix, which is why people read the first one and stop. The useful line is the one with a number in it: "npm ERR! network 503 Service Unavailable - GET https://registry.npmjs.org/react" on npm 9 and earlier, or the same wording behind an "npm error" prefix on npm 10 and later. Under it npm prints a code, and the code is the whole diagnosis. 500, 502, 503 and 504 are the registry failing. A throttle is the registry telling you to slow down. The E-codes are the connection dying before an answer arrived. A 404 in the same shape is not a network failure at all, and neither is an unsupported protocol. The run below points npm at a stub registry on loopback rather than at registry.npmjs.org, because a 503 from the real one cannot be produced on demand; the error line is npm's own, printed after its built-in retries were exhausted.

```Actions log, install step
npm error code E503
npm error 503 Service Unavailable - GET http://127.0.0.1:8899/lodash - Service Unavailable
npm error A complete log of this run can be found in: /home/runner/.npm/_logs/2026-09-20T08_09_38_389Z-debug-0.log
```

## Common causes

### The registry answered with an error of its own

The ordinary case, and the reason the same commit passes twenty minutes later. A public registry serving the whole industry has bad minutes, and a 5xx during them is not information about your project. It is also the case a retry fixes outright, which is why it is worth separating from the rest before you spend an afternoon on lockfiles.

### The retry you added is not wrapped around the npm that failed

This is the most common wasted fix on this failure. A retry action around a build step does not reach an install running inside a container build, and `npm config set` in an earlier step writes a file the build container never sees. In our experience a workflow that keeps failing after a retry was added is usually failing inside a Dockerfile, where none of the runner-level configuration exists.

### The connection dropped part way through a download

A reset or a socket timeout mid-transfer is a different shape from a clean error response: the registry was fine and the path to it was not. Parallel installs make it likelier, because a job opening fifty connections through one shared egress address notices the first drop.

### It is not a network failure at all

Two codes share the wording and none of the cure. A 404 means the package name, the version or the registry path is wrong, and retrying it is how a five minute problem becomes a twenty minute one. `EUNSUPPORTEDPROTOCOL` means the manifest uses a dependency scheme that belongs to a different package manager, and no amount of network configuration will teach npm to read it.

## How to fix it

### Raise the retry budget where every npm can see it

1. Put the four `npm_config_*` values in the job's `env:` block, not in a `run:` step.
2. Raise the minimum wait as well as the count, so the retries land after the blip instead of inside it.
3. Leave the outer retry action off until you have done this; two retry layers on top of each other mostly buy billed minutes.

```.github/workflows/ci.yml
jobs:
  build:
    runs-on: ubuntu-latest
    env:
      npm_config_fetch_retries: '5'
      npm_config_fetch_retry_mintimeout: '20000'
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm
      - run: npm ci --no-audit --no-fund
```

### Move the settings to where the failing install runs

If the error comes from inside a container build, the configuration has to be in the image. Set the values with `ENV` in the Dockerfile, or mount an `.npmrc` as a build secret when it also carries a token. A `container:` job is the same story with a different lever: its `env:` is the one that counts.

```Dockerfile
# BuildKit, with the token kept out of the layer
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci --no-audit --no-fund
```

### Open fewer connections and keep the store

Lower the socket count when resets are frequent, and cache the package store so most of the install never touches the network. `setup-node` with `cache: npm` restores the store npm reads from, which is a much better lever than caching `node_modules`.

```Terminal
npm config set maxsockets 8
# or per job
env:
  npm_config_maxsockets: '8'
```

### Read the code before you retry anything

A 404 needs the name, the version or the registry URL corrected, and a private package needs a token that the runner has. An unsupported protocol needs the package manager the repository was written for: enable Corepack and run that one instead of npm.

```Terminal
npm config get registry
corepack enable
pnpm install   # for a manifest using pnpm catalogs
yarn install   # for portal: or link: specifiers
```

## How to prevent it

- Set the npm retry budget in the job environment once, rather than per command.
- Cache the npm store with `setup-node`, so a registry blip cannot stop most installs.
- Keep a private registry token in secrets, so a 401 never arrives dressed as a network error.
- Pin the package manager with `packageManager` so the wrong tool never runs the install.

## The code under the line is the diagnosis

Every failure below prints the same word, and they do not have the same fix. Sort yours before you change anything, because four of these clear on a retry and two of them never will.

| Code | What happened | Worth retrying |
| --- | --- | --- |
| 500, 502, 503, 504 | The registry answered with an error of its own | Yes |
| A 429 response | The registry is throttling this client | Yes, after a wait |
| `ETIMEDOUT`, `ESOCKETTIMEDOUT` | The connection never completed | Yes |
| `ECONNRESET` | The connection was dropped part way through | Yes |
| `EAI_AGAIN`, `ENOTFOUND` | DNS did not answer, or answered no such name | Only the first |
| A 404 response | That package or that registry path does not exist | No |
| `EUNSUPPORTEDPROTOCOL` | The manifest uses a scheme npm has no resolver for | No |

> The DNS pair has its own page, because the fix is in the resolver rather than in npm: [EAI_AGAIN and could not resolve host in GitHub Actions](/learn/failures/eai-again-could-not-resolve-host-in-ci).

## npm already retried, which is why you are reading the error

npm does not give up on the first failed request. It retries twice by default, waiting 10 seconds and then up to a minute, and it gives each request 5 minutes before it calls it a timeout. The line in your log is what it prints once that budget is spent, which is what most workflows get wrong: an outer retry on top of a budget nobody raised just waits out the default attempts twice.

Raising the budget is one setting, and in CI it belongs in the environment rather than in a command you have to remember to repeat. Every npm config key has an environment variable, so one `env:` block covers every npm in the job.

```.github/workflows/ci.yml
env:
  npm_config_fetch_retries: '5'
  npm_config_fetch_retry_mintimeout: '20000'
  npm_config_fetch_retry_maxtimeout: '120000'
  npm_config_fetch_timeout: '600000'
```

## The retry has to reach the npm that actually failed

A step-level retry action wraps the command the workflow runs. It does nothing for an npm running three layers down: inside `docker build`, inside a `container:` job, inside a tool that shells out to npm for you. Those processes read their own environment and their own `.npmrc`, and they have never heard of the runner's.

The test is simple. If the failing line appears under a `#N [builder N/M] RUN npm ci` heading, the settings have to be in the image, not in the workflow, and a retry belongs around the build rather than around the install.

```Dockerfile
# Dockerfile
ENV npm_config_fetch_retries=5 \
    npm_config_fetch_retry_mintimeout=20000
RUN npm ci --no-audit --no-fund
```

## What the runner does about it

Latchkey carries three separate detections for this family rather than one, because the right wait is different for each. `NPM_NETWORK_REGISTRY_5XX` fires on a registry 5xx at confidence 0.95 and retries with a 2 second base backoff. `NPM_NETWORK_TIMEOUT` fires on the E-codes at 0.94 with the same short backoff. `NPM_REGISTRY_RATE_LIMIT` fires on a throttle at 0.94 and starts at 60 seconds instead, because, in the library's own words, "the limit recharges much slower than a 5xx blip".

The recorded run above is the first of those. npm exhausted its own retries against a registry answering 503, the runner retried the step, and the second attempt installed the package. The pattern library is explicit about why that line is safe to act on: a registry that fails twice and then succeeds leaves npm exiting 0 with no error token in either stream, so a 503 in the buffer means npm had already given up.

## FAQ

### Why does npm install fail in CI with no change to package.json?

Because the failure is in the path to the registry rather than in the manifest. The same lockfile resolves the same versions every time, so a run that fails today and passes tomorrow failed on a request, not on a dependency. Read the code in the error: a 5xx or an E-code is the network, a 404 is the manifest.

### How many times does npm retry a failed registry request?

Twice by default, on top of the first attempt. It waits 10 seconds before the first retry and up to a minute before the second, and it allows each request 5 minutes overall. Those are the `fetch-retries`, `fetch-retry-mintimeout`, `fetch-retry-maxtimeout` and `fetch-timeout` defaults, and all four have environment variables.

### Why does the npm network error only happen inside docker build?

Because the build container has its own environment, its own `.npmrc` and often its own DNS. None of the settings you applied on the runner reach it. Set the npm values with `ENV` in the Dockerfile, and check whether the build stage has a working resolver before assuming the registry is at fault.

### Should I run a registry mirror to stop npm network errors?

It helps when the volume is yours: a proxy turns most installs into a local fetch and takes the shared egress address out of the picture. It is a bigger change than raising the retry budget, so do it when registry failures are frequent rather than after one red build.

## References

- [npm CLI config: fetch-retries and the other network defaults](https://docs.npmjs.com/cli/v10/using-npm/config)
- [npm: common errors](https://docs.npmjs.com/common-errors)
- [Node.js: common system errors](https://nodejs.org/api/errors.html#common-system-errors)
- [npm/cli#4085: network errors and the retry settings](https://github.com/npm/cli/issues/4085)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
