# playwright chromium download failed ci, five times over

> When playwright chromium download failed ci appears, five attempts have run, over three CDN hosts unless you set a download host. Here is the fix.

Source: https://latchkey.dev/learn/failures/playwright-browser-download-failed-in-ci  
Updated: 2026-09-20

When playwright chromium download failed ci shows up in a log, `npx playwright install` has already tried five times and given up, and the five attempts are printed one after another so the log looks far worse than the event. The useful question is not why it failed but which host it failed against, because Playwright rotates across three CDN mirrors by default and stops rotating the moment you set a download host of your own.

## What this error means

The install step prints a `Downloading ...` line, then an `Error: Download failed: ...` block, and repeats that pair five times before ending with `Failed to install browsers` and a wrapper error naming the browser. Each attempt carries the full URL, which is the field to read. The block below is a container running playwright 1.63.0 with `PLAYWRIGHT_DOWNLOAD_HOST` pointed at a server that answers 503, and it is the first of the five attempts plus the summary that followed the fifth. This page carries no reproduction on a Latchkey runner, so the host is ours. The browser name and revision are the real ones that release ships.

```playwright 1.63.0, download host answering 503
Downloading Chrome for Testing 153.0.8010.12 (playwright chromium v1243) from http://mirror503:8899/builds/cft/153.0.8010.12/linux-arm64/chrome-linux-arm64.zip
Error: Download failed: server returned code 503 body 'maintenance
'. URL: http://mirror503:8899/builds/cft/153.0.8010.12/linux-arm64/chrome-linux-arm64.zip
[attempts two through five, the same two lines each time, elided]
Failed to install browsers
Error: Failed to download Chrome for Testing 153.0.8010.12 (playwright chromium v1243), caused by
Error: Download failure, code=1
```

## Common causes

### The install step downloads browsers on every run

A workflow with no browser cache fetches roughly a hundred megabytes of archives in every job, on every matrix leg. That is the exposure. Everything else on this page is about what happens when one of those downloads goes wrong, and the most effective fix is to stop making the request.

### A download host of your own turned five attempts into one host, five times

An internal mirror or an artifact proxy set through `PLAYWRIGHT_DOWNLOAD_HOST` removes the CDN rotation. When that mirror is the thing having a bad minute, all five attempts fail identically, in a few seconds, and the log looks like a hard outage rather than a retry that ran out.

### Egress to the CDN is blocked or filtered

Self hosted runners behind a proxy, or a network policy that allows the npm registry but not the Playwright CDN, produce a timeout rather than a status code. The message carries a millisecond figure and no status, which is the tell. Playwright documents an `HTTPS_PROXY` path for exactly this situation.

### A version bump changed the archive being requested

Each Playwright release pins a browser build, and 1.63.0 asks for Chrome for Testing 153.0.8010.12 as Chromium build v1243. After an upgrade the URL changes, so a mirror that was only ever populated with the old archive starts answering 404 while the CDN would have served it. In our experience this is the one that gets reported as a Playwright bug.

## How to fix it

### Cache the browsers, keyed to the Playwright version

1. Read the installed Playwright version from the lockfile at run time and put it in the cache key, so an upgrade invalidates the cache instead of restoring binaries the new version will not use.
2. Cache the browsers directory the platform actually uses, which is `~/.cache/ms-playwright` on Linux runners.
3. Keep the install step in the workflow. On a cache hit it finds the marker file and returns without downloading anything.

```.github/workflows/ci.yml
- id: pw
  run: echo "v=$(node -p "require('./node_modules/playwright-core/package.json').version")" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
  with:
    path: ~/.cache/ms-playwright
    key: pw-${{ runner.os }}-${{ steps.pw.outputs.v }}
- run: npx playwright install --with-deps chromium
```

### Install only the browsers the job runs

Naming the browser cuts the download to a third or less and removes two chances to fail. A project that only runs Chromium in CI has no reason to fetch Firefox and WebKit on every job, and the argument is positional, so it goes after the subcommand.

```Terminal
npx playwright install --with-deps chromium
```

### If you run a mirror, keep the CDN as a fallback path

Because a custom download host replaces the rotation rather than adding to it, a mirror is a single point of failure for the install. Decide that deliberately: either keep the mirror and accept that its outage is your outage, or leave the variable unset on the jobs that can reach the public CDN and use the mirror only where egress is restricted.

```.github/workflows/ci.yml
- run: npx playwright install --with-deps chromium
  env:
    PLAYWRIGHT_DOWNLOAD_HOST: ${{ vars.PW_MIRROR }}
```

### Point the proxy variables at your proxy for restricted runners

When the runner reaches the internet through a proxy, give the install the proxy rather than a mirror. This keeps the three way CDN rotation intact, which is the property you want, while still satisfying a network that requires everything to go through one egress point.

```.github/workflows/ci.yml
- run: npx playwright install --with-deps chromium
  env:
    HTTPS_PROXY: http://proxy.example.com:8080
    HTTP_PROXY: http://proxy.example.com:8080
```

## How to prevent it

- Cache `~/.cache/ms-playwright` with the Playwright version in the key, and read that version from the lockfile rather than hardcoding it.
- Install one browser per job instead of all of them.
- Treat a custom download host as a dependency with its own availability, because it replaces the CDN rotation rather than supplementing it.
- Re-populate an internal mirror as part of upgrading Playwright, in the same change that bumps the version.

## Five attempts, and how many hosts they reach

Playwright retries the browser download a fixed number of times, and it picks the URL for each attempt by cycling through the list of download URLs it built. The retry count in `browserFetcher.ts` is 5, and the URL for attempt `n` is the list entry at position `(n - 1)` modulo the list length. So the number of distinct hosts your five attempts reach is the length of that list.

By default the list holds three entries, labeled in the source as the ESRP CDN at `cdn.playwright.dev`, the same CDN hit directly at `playwright.download.prss.microsoft.com`, and the storage bucket at `cdn.playwright.dev`. Five attempts cycling through three entries means the first two are each tried twice and the third once, which is genuine redundancy against one endpoint having a bad minute.

Set `PLAYWRIGHT_DOWNLOAD_HOST` and that list becomes one entry long. All five attempts then hit the same host, one after another, with no spacing between them worth the name. That is the trade an internal mirror makes, and it is worth knowing before you conclude that Playwright does not retry.

Counting requests at the server during the run above confirms the arithmetic: exactly five GETs for the archive arrived, all for the same URL, because the download host had collapsed the list to one.

| Download host setting | URLs in the rotation | What five attempts look like |
| --- | --- | --- |
| Unset, the default | Three CDN entries | Two hosts tried twice, one tried once. |
| `PLAYWRIGHT_DOWNLOAD_HOST` set | One entry | The same host five times in a row. |
| `PLAYWRIGHT_CHROMIUM_DOWNLOAD_HOST` set | One entry, for Chromium only | Chromium pinned, other browsers still rotate. |
| Browsers restored from a cache | None | No download is attempted at all. |

> The per browser variables take precedence over `PLAYWRIGHT_DOWNLOAD_HOST`, so a Chromium specific host wins for Chromium even when a general one is also set.

## A timeout looks different from a refusal

The 503 message above names a status code because the server answered. When nothing answers, the download times out instead, and the message says so with a number in it. Playwright passes a socket timeout into the download process; it comes from `NET_DEFAULT_TIMEOUT`, which is 30000 milliseconds, and `PLAYWRIGHT_DOWNLOAD_CONNECTION_TIMEOUT` overrides it. Pointing the download host at the unroutable documentation address gave five of these, thirty seconds apart.

This matters because the two have different fixes. A status code is a server saying no, and a retry or a different mirror is the answer. A timeout with no status is the runner not reaching the host at all, and no amount of retrying inside one job will change that.

```playwright 1.63.0, download host at an unroutable address
Error: Request to https://192.0.2.1/builds/cft/153.0.8010.12/linux-arm64/chrome-linux-arm64.zip timed out after 30000ms
```

## This is not the same error as a missing executable

The failure that reads `Executable doesn't exist at ...` followed by a boxed instruction to run `npx playwright install` is a different event with a different fix. That one means the download never happened in this job, or a cache restored binaries for a different revision. It is raised at launch, by the test run, not by the install step.

A download failure is raised by the install step itself and always carries a URL. If your log has a URL, you are on this page. If your log has a path under a browsers directory and a box of text asking you to run the install command, the fix is to run the install step, or to fix the cache key so a stale restore stops satisfying it.

## Why this page has no runner reproduction

The CDN Playwright downloads from is not something we can make fail, and waiting for it to fail on its own is not a method. Pointing the documented download host variable at a server we control produces the real code path, the real retry count and the real message, and it costs nothing. The Playwright documentation itself uses `http://192.0.2.1` in its example for this variable, which is the same reserved documentation address used for the timeout case above.

There is no entry for this slug in `content/heal-evidence.mjs`, so nothing here is marked `healable` and no repair is described. A browser download that fails five times against one host is a workflow design problem more than a transient one, and the cache fix below removes it rather than papering over it.

## FAQ

### How many times does npx playwright install retry a failed download?

Five. The retry count is fixed in Playwright's browser fetcher, and each attempt takes the next URL from the list of download hosts, wrapping around when it runs out. With the default three CDN entries that means two hosts are tried twice and one once; with a custom download host set, all five attempts go to the same place.

### Why does Playwright download browsers again after I cached them?

Because the cache key did not change when the Playwright version did, so a restore brought back binaries for a different revision and the install still has work to do. Put the installed Playwright version in the key. The install step is cheap on a hit: it looks for a marker file in the browser directory and returns immediately when it is there.

### Does PLAYWRIGHT_DOWNLOAD_HOST make browser installs more reliable?

Not on its own. It replaces the three entry CDN rotation with a single host, so it trades the public CDN's redundancy for control over the bytes. That is the right trade on a restricted network and the wrong one if you set it because you thought it was a fallback.

### Do I need to install all three browsers in CI?

Only the ones your tests launch in that job. Passing the browser name after the install subcommand fetches just that one, which cuts both the download size and the number of independent things that can fail. Installing everything by default is the most common reason a Playwright install step is slow.

## References

- [Playwright docs: install behind a firewall or a proxy, and PLAYWRIGHT_DOWNLOAD_HOST](https://playwright.dev/docs/browsers#install-behind-a-firewall-or-a-proxy)
- [microsoft/playwright: browserFetcher.ts, the five attempt retry loop](https://github.com/microsoft/playwright/blob/v1.63.0/packages/playwright-core/src/server/registry/browserFetcher.ts)
- [microsoft/playwright: registry index.ts, the CDN mirror list and download host precedence](https://github.com/microsoft/playwright/blob/v1.63.0/packages/playwright-core/src/server/registry/index.ts)
- [Playwright docs: continuous integration and caching browsers](https://playwright.dev/docs/ci)

---

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
