Caching Playwright browsers in GitHub Actions
This page is part of the GitHub Actions Workflows section of the CI/CD test gating guide, which covers keeping component and visual jobs both correct and fast enough to stay required on every pull request.
The specific problem: every run of your visual regression workflow spends a minute or more re-downloading the same three browser engines before a single test executes. Nothing changed between runs — the runner was simply recycled and Playwright’s browser directory went with it.
Problem statement
npx playwright install fetches Chromium, Firefox, and WebKit into ~/.cache/ms-playwright. On a GitHub-hosted runner that directory is empty at the start of every job, so the command downloads roughly 300 MB and unpacks it before your tests can run. Across a busy repository — dozens of PRs a day, each triggering the workflow on every push — that is minutes of wall-clock time and bandwidth spent re-fetching bytes that never changed.
The symptom is a workflow whose Install browsers step consistently takes 45–90 seconds while the resolved Playwright version in package-lock.json has not moved in weeks. That step is pure waste, and it lengthens the gated check that every PR waits on.
Root cause
GitHub-hosted runners are ephemeral: the filesystem is discarded when the job ends, so ~/.cache/ms-playwright — where Playwright caches its browser builds — starts empty on the next run. Without an actions/cache step that persists and restores that directory, npx playwright install has no choice but to re-download everything. Because browser builds are pinned to a specific Playwright release, the cache only needs to change when the resolved @playwright/test version changes, which is why the cache key should track that version rather than a whole-lockfile hash. Keeping this job fast matters because it runs inside the same gated pipeline as sharding Playwright visual tests across CI runners.
Minimal reproduction
The unoptimized workflow: browsers are installed from scratch on every trigger because nothing persists ~/.cache/ms-playwright between runs.
# .github/workflows/e2e.yml — re-downloads browsers every run
name: E2E
on: pull_request
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx playwright install --with-deps # ~300 MB, every single run
- run: npx playwright test
The npm cache from setup-node speeds up npm ci, but it does nothing for the browser binaries — those live under ~/.cache/ms-playwright, which setup-node never touches. The Install step downloads the full browser set on run 1, run 2, and every run after.
Step-by-step fix
1. Derive the exact Playwright version from the lockfile
The cache key must change exactly when the browser builds change — that is, when the resolved @playwright/test version moves. Read it from package-lock.json and expose it as a step output.
- name: Resolve Playwright version
id: pw-version
run: |
VERSION=$(node -p "require('@playwright/test/package.json').version")
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
What this does: require('@playwright/test/package.json').version reads the version actually installed by npm ci (so it reflects the lockfile, not a floating range). Writing version=… to $GITHUB_OUTPUT makes it available as steps.pw-version.outputs.version in later steps and in the cache key.
2. Restore the browser cache keyed on OS and version
Use actions/cache@v4 on ~/.cache/ms-playwright with a key built from the runner OS and the resolved version. Give the step an id so later steps can read whether it was a hit.
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ steps.pw-version.outputs.version }}
restore-keys: |
${{ runner.os }}-playwright-
What this does: on a hit, the entire ms-playwright directory is restored before the install step runs. The restore-keys fallback lets an older version’s cache seed a partial restore after a version bump, so even a miss is not a fully cold download. runner.os in the key keeps Ubuntu and macOS caches separate, since their browser builds differ.
3. Install browsers only on a cache miss
Guard the download with the cache step’s output so it runs once per version and is skipped on every hit.
- name: Install browsers (cache miss only)
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps
What this does: cache-hit is the string 'true' only on an exact-key match. When the cache is restored, this step is skipped entirely and no browsers are downloaded. On a miss — first run, or after a version bump — it downloads and --with-deps also installs the system libraries in the same step, and the cache action saves the populated directory at the end of the job.
4. Install system dependencies on a cache hit
The cache restores browser binaries but not the apt libraries they link against — those are wiped with the runner. On a hit, run install-deps alone to restore them without re-downloading browsers.
- name: Install system deps (cache hit)
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps
What this does: playwright install-deps runs apt-get to install the shared libraries (fonts, GTK, NSS, and similar) that Chromium and WebKit need at runtime. It downloads a few megabytes of packages, not the browsers, so a cache hit still skips the expensive part. Skip this step and cached browsers can fail to launch with error while loading shared libraries.
5. Assemble the complete workflow
Putting the pieces in order gives a job that downloads browsers at most once per Playwright version.
# .github/workflows/e2e.yml — cached
name: E2E
on: pull_request
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- name: Resolve Playwright version
id: pw-version
run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ steps.pw-version.outputs.version }}
restore-keys: ${{ runner.os }}-playwright-
- name: Install browsers (cache miss only)
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps
- name: Install system deps (cache hit)
if: steps.playwright-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps
- run: npx playwright test
What this does: the first run on a new version populates the cache; every subsequent run restores it and skips the download. The browser-fetch cost drops from every run to once per version bump.
Verification
On the second run of the workflow, the cache restore logs a hit and the install step is skipped. Inspect the run:
gh run view 1547823901 --log | grep -A1 "ms-playwright"
Expected on a hit:
Cache restored successfully
Cache restored from key: Linux-playwright-1.48.2
The install step’s log confirms it was skipped:
Install browsers (cache miss only)
This step was skipped because its condition was not met.
You can also assert the cost dropped by comparing step durations across runs:
gh run view 1547823901 --json jobs \
--jq '.jobs[0].steps[] | select(.name|test("Install|Cache")) | {name, started_at, completed_at}'
The Cache Playwright browsers step should complete in a few seconds, and the Install browsers step should report as skipped rather than spending 45–90 seconds downloading. A cold cache (first run after a version bump) will still download once, then every run after it is fast again.
Edge cases and caveats
- Keying on the lockfile hash busts the cache too often. It is tempting to reuse
hashFiles('package-lock.json')as the key, but that changes on any dependency bump — an unrelatedlodashupdate would throw away a valid browser cache and trigger a full re-download. Key on the resolved@playwright/testversion so the cache only rotates when the browsers actually change. - PLAYWRIGHT_BROWSERS_PATH must match the cached path. If your project sets
PLAYWRIGHT_BROWSERS_PATHto a custom directory (common in monorepos to share browsers across packages), cache that directory instead of~/.cache/ms-playwright. A mismatch means you cache an empty folder and still download every run, with no error to signal it. - Only
--with-depsruns apt; the cached path holds browsers alone.playwright install --with-depsboth downloads browsers and installs system libraries, but the cache only preserves the browser binaries. That split is why a cache hit needs a separateinstall-deps— the two halves live in different places, and only one of them survives in the cache.
FAQ
Why is npx playwright install downloading browsers on every CI run?
Playwright stores browser binaries in ~/.cache/ms-playwright, which is wiped when the GitHub-hosted runner is recycled after each job. Without an actions/cache step that persists and restores that directory, every run starts empty and re-downloads roughly 300 MB of Chromium, Firefox, and WebKit.
What should the Playwright browser cache key be based on?
Key the cache on the runner OS plus the exact resolved @playwright/test version, not on a lockfile hash. Browser builds are pinned to a Playwright version, so the cache only needs to change when that version changes. Keying on the whole package-lock hash busts the cache on every unrelated dependency bump.
Do I still need to run playwright install-deps when the cache hits?
Yes. actions/cache restores the browser binaries in ~/.cache/ms-playwright, but the system libraries those binaries link against (installed via apt) live outside that directory and are gone when the runner is recycled. Run npx playwright install-deps on a cache hit to restore them without re-downloading the browsers.
Related
- GitHub Actions Workflows — the parent section covering how component and visual jobs are wired into Actions pipelines.
- Gating Visual Regression Tests in GitHub Actions — make the fast, cached visual job actually block a bad merge.
- Sharding Playwright Visual Tests Across CI Runners — split the suite across runners once each runner’s browser install is cached.
- Handling Font Rendering Differences Across Browsers — why the cached browser set must be pinned for stable screenshots.
- CI/CD Test Gating & Pipeline Integration — the top-level guide to gating component tests across CI providers.