playwright chromium download failed ci, five times over
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.
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=1Five 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. |
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
- 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.
- Cache the browsers directory the platform actually uses, which is
~/.cache/ms-playwrighton Linux runners. - Keep the install step in the workflow. On a cache hit it finds the marker file and returns without downloading anything.
- 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 chromiumInstall 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.
npx playwright install --with-deps chromiumIf 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.
- 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.
- run: npx playwright install --with-deps chromium
env:
HTTPS_PROXY: http://proxy.example.com:8080
HTTP_PROXY: http://proxy.example.com:8080A 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.
Error: Request to https://192.0.2.1/builds/cft/153.0.8010.12/linux-arm64/chrome-linux-arm64.zip timed out after 30000msThis 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.
How to prevent it
- Cache
~/.cache/ms-playwrightwith 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.
Frequently asked questions
How many times does npx playwright install retry a failed download?
Why does Playwright download browsers again after I cached them?
Does PLAYWRIGHT_DOWNLOAD_HOST make browser installs more reliable?
Do I need to install all three browsers in CI?
Related guides
References
- Playwright docs: install behind a firewall or a proxy, and PLAYWRIGHT_DOWNLOAD_HOST
- microsoft/playwright: browserFetcher.ts, the five attempt retry loop
- microsoft/playwright: registry index.ts, the CDN mirror list and download host precedence
- Playwright docs: continuous integration and caching browsers
- GitHub Actions documentation