Caching Playwright Browsers in CI
Every cold CI job that runs npx playwright install pulls a fresh copy of Chromium, Firefox, and WebKit over the network — close to a gigabyte on disk once unpacked, and sixty to a hundred and twenty seconds of wall clock before a single test starts. Multiply that by a matrix of shards and the download dominates the pipeline. Caching the browser directory removes it, but a cache that is keyed wrong is worse than no cache at all: it serves binaries that no longer match the installed library, and the suite dies with Executable doesn't exist at /home/runner/.cache/ms-playwright/chromium-1148/chrome-linux/chrome on a job that reported a cache hit. This page shows exactly what to cache, how to key it, and what still has to run on every job.
Root cause: the library and the binaries are cached separately
npm ci installs @playwright/test into node_modules, but the browser builds go somewhere else entirely — ~/.cache/ms-playwright on Linux, ~/Library/Caches/ms-playwright on macOS, %USERPROFILE%\AppData\Local\ms-playwright on Windows — in directories named after the internal build number, such as chromium-1148. Those build numbers are chosen by the Playwright release you installed, so the library and the binaries form a matched pair that has to be invalidated together. A cache keyed on anything looser than the resolved Playwright version will eventually hand a new library an old binary, and the launcher fails immediately because the path it computes does not exist. The same directory also holds the bundled ffmpeg build used for video recording, which is why a partially restored cache can produce a suite that runs but silently drops its recordings. PLAYWRIGHT_BROWSERS_PATH moves the whole directory somewhere else, and it must be set before the install step as well as before the test run — set in one place and not the other, the install writes to a location the runner never reads back. This page sits under CI/CD Integration in Playwright Setup & Core Architecture.
Minimal reproducible example
The default failure is bad diagnostics: the first test in the first worker throws, every other worker throws the same thing, and the report is a wall of identical timeouts. A global setup that resolves each engine's expected path before any test runs converts that into one explicit failure naming the directory that should have been restored.
import { chromium, firefox, webkit, type BrowserType, type FullConfig } from '@playwright/test';
import { existsSync } from 'node:fs';
// Registered as `globalSetup` so it runs once, before any worker spawns.
async function globalSetup(_config: FullConfig): Promise<void> {
// Only assert on engines this pipeline actually launches.
const engines: Array<[string, BrowserType]> = [
['chromium', chromium],
['webkit', webkit],
['firefox', firefox],
];
const missing: string[] = [];
for (const [name, engine] of engines) {
// executablePath() returns the path Playwright *expects*, computed from
// the installed package version — it does not check that the file is there.
const expected = engine.executablePath();
if (!existsSync(expected)) missing.push(`${name}: ${expected}`);
}
if (missing.length > 0) {
// One clear error beats N identical launch timeouts in the report.
throw new Error(
`Browser binaries missing — the CI cache restored the wrong key.\n${missing.join('\n')}\n` +
`Run: npx playwright install --with-deps`,
);
}
}
export default globalSetup;
Point the config at that file, and list only the engines the pipeline needs, so the install command and the cache stay in step with the projects that actually run.
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
globalSetup: './global.setup.ts',
// Two projects means two engines to download, not three.
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
});
Step-by-step fix
- Cache the browser directory, not
node_modules. On Linux runners the path is~/.cache/ms-playwright. If your CI system can only cache paths inside the workspace — GitLab is the common case — setPLAYWRIGHT_BROWSERS_PATHto a directory under the project root before any install step, and cache that instead. Setting it to0forces installation intonode_modules, which couples two caches with different invalidation rules and is worth avoiding. - Derive the key from the resolved Playwright version. Read it out of the lockfile rather than hard-coding it:
jq -r '.packages["node_modules/@playwright/test"].version' package-lock.json. Prefix the key with the runner OS and the exact runner image tag, because the WebKit and headless-shell builds are compiled per distribution and a binary built for one Ubuntu release will not load on another. - Restore before installing, and install only on a miss. Put the cache step ahead of the install step and gate the download on
cache-hit != 'true'. On a miss the install repopulates the directory and the post-job save stores it under the exact key; on a hit the download never runs. - Install operating-system dependencies on every job. The cache holds browser binaries, not the
aptpackages they link against, and a fresh runner has none of them. Runnpx playwright install-depson the hit branch. Skipping this is what producesHost system is missing dependencies to run browsersand errors naminglibnss3.soorlibgbm.so.1on a job whose cache restored perfectly. - Install only the engines your projects use. Pass browser names —
npx playwright install --with-deps chromium webkit— so a pipeline that never runs Firefox does not cache it. On recent Playwright releases the headless Chromium shell downloads separately, and--only-shellskips the headed Chromium build entirely for suites that never run headed. Decide which engines belong on every commit versus a nightly job using Cross-Browser Execution. - Skip
restore-keysand make sure the default branch populates the cache. A prefix restore key returns the previous version's directory, the install step then adds the new builds beside the old ones, and the saved entry grows with every upgrade until it evicts everything else in the repository's cache budget. Separately, caches written on a topic branch are not readable from sibling branches, so run the workflow on pushes to the default branch or the first job of every pull request will miss. - Fail fast when the cache lies. Wire in the global setup from above so a mismatched restore reports one error naming the expected path, instead of every worker timing out. That signal is also what keeps retry and timeout tuning honest — an infrastructure failure should never be retried three times before anyone sees it.
The workflow in full
The steps above fit into a single job. This is the GitHub Actions shape; the same ordering applies to any runner, and it composes with the matrix described in Running Playwright Tests in GitHub Actions with Sharding, where every shard reads the same cache entry.
name: e2e
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm # caches the npm download cache, not the browsers
- run: npm ci
# Read the version the lockfile actually resolved - never hard-code it.
- name: Resolve Playwright version
id: pw
run: |
VERSION=$(jq -r '.packages["node_modules/@playwright/test"].version' package-lock.json)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Restore browser cache
id: pw-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
# Image tag is part of the key: WebKit builds are distro-specific.
key: ubuntu-24.04-playwright-$
# No restore-keys on purpose - a partial hit accumulates stale builds.
- name: Install browsers (cache miss only)
if: steps.pw-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium webkit
- name: Install system libraries (cache hit)
if: steps.pw-cache.outputs.cache-hit == 'true'
run: npx playwright install-deps chromium webkit
- run: npx playwright test
The same key in GitLab CI
GitLab only caches paths inside $CI_PROJECT_DIR, so the home-directory default is unreachable. Override PLAYWRIGHT_BROWSERS_PATH at the job level and cache that directory. Because GitLab restores caches on a best-effort basis, keep the install command unconditional — on a warm cache it verifies the existing builds in a couple of seconds rather than downloading them.
variables:
PLAYWRIGHT_BROWSERS_PATH: "$CI_PROJECT_DIR/.cache/ms-playwright"
e2e:
image: node:20-bookworm
cache:
key:
files:
- package-lock.json # any dependency change rotates the key
paths:
- .cache/ms-playwright
script:
- npm ci
- npx playwright install --with-deps chromium webkit
- npx playwright test
The payoff is bounded and easy to predict. A cold job on a hosted Linux runner spends roughly ninety seconds between npm ci finishing and the first browser launching, almost all of it transferring and unpacking archives; a warm job replaces that with a cache download of the same bytes over the provider's internal network, typically ten to thirty seconds, and an install command that only stats files. The absolute saving is per job, so it multiplies across a sharded matrix — eight shards each save the same download, and the merge job waits on the slowest of them. That makes the install step duration, not the total pipeline time, the number worth tracking after you make this change.
Troubleshooting variants
The job reports a cache hit but tests fail with "Executable doesn't exist"
The restored directory belongs to a different Playwright release. The full message names the build number Playwright expected — chromium-1148 — followed by the banner reading Looks like Playwright Test or Playwright was just installed or updated. Please run the following command to download new browsers: npx playwright install. Compare that build number against what is on disk with ls ~/.cache/ms-playwright. A mismatch almost always means the key omitted the version, or a restore-keys prefix matched an older entry. Remove the prefix keys, put the lockfile-resolved version in the key, and re-run; the first job after the change misses by design.
Cache hit, then libnss3.so: cannot open shared object file
The binaries came back, the system libraries did not. The cache captured only the browser directory, and a fresh runner ships without the apt packages Chromium and WebKit link against. Add npx playwright install-deps to the hit branch, or drop the conditional and always run npx playwright install --with-deps — on a warm cache the browser portion is a fast verification. If installing packages needs elevation the command must run under sudo on self-hosted runners. Baking the libraries into an image removes the problem permanently, which is the argument for Dockerizing Playwright for Headless CI.
Every pull request misses, but pushes to the default branch hit
This is cache scoping, not a key problem. A cache entry written by a job on a topic branch is visible to that branch and to branches created from it, but never to unrelated branches; only entries written on the default branch are readable everywhere. If the workflow runs on pull_request alone, nothing ever writes an entry the next pull request can read. Add a push trigger on the default branch. Watch the storage budget too: three engines occupy roughly a gigabyte per key, entries unused for a week are evicted, and a repository over its limit loses its least-recently-used entries — so a wide OS matrix can quietly evict its own keys between runs.
Verification
Confirm the cache is real, not theoretical, in three passes. First, read the workflow log for the restore line naming the key it matched, then check the install step: on a warm job it should print the resolved versions without any Downloading Chromium progress output, and finish in seconds. Second, run npx playwright install --dry-run as a debugging step — it prints the install location and the download URL for each engine without fetching anything, which is the quickest way to see whether the path Playwright computes matches the path the cache restored. Third, compare the step duration across a deliberately cold run and the run after it; the delta should be the whole download, and if it is not, the key is rotating on every job. A fourth check is worth running once, by hand: delete the entry from the cache list, re-run the job, and confirm it goes green on the miss branch as well as the hit branch. Pipelines that only ever exercise the warm path hide broken install commands for months, until a version bump invalidates every key at once and the whole matrix fails together. Once both paths are proven, artifact collection and reporting behave predictably, as covered in Capturing Screenshots and Video on Test Failure.
Frequently Asked Questions
Should the browsers and node_modules share one cache entry?
Keep them separate. They invalidate on different signals — node_modules on the full lockfile, the browsers only on the Playwright version — so combining them throws away a valid gigabyte of binaries every time an unrelated dependency bumps a patch release. Note that actions/setup-node with cache: npm caches the npm download cache, not node_modules itself, so it never covers browser binaries either way.
Should the key use the browser build number instead of the Playwright version?
Use the Playwright version. Build numbers such as chromium-1148 are chosen by the release you installed, so the version is a superset of them and is readable from the lockfile before anything is installed. Keying on build numbers means resolving three separate values and keeping them in sync by hand, with no benefit — two releases that happen to ship identical builds only cost you one extra download.
Can one cache entry serve both ubuntu-22.04 and ubuntu-24.04 runners?
No. WebKit and the headless Chromium shell are compiled against the system libraries of a specific distribution release, so Playwright downloads a different artifact per image even at the same version. Restoring across images produces binaries that fail to load with linker errors. Put the image tag in the key and let each image keep its own entry.
Is caching worth it when tests already run in the official Playwright image?
No, and doing it is actively harmful. The mcr.microsoft.com/playwright image already contains the browsers at /ms-playwright with PLAYWRIGHT_BROWSERS_PATH pointed at them, so a cache step would restore a second copy over a directory that is already correct. Pin the image tag to your Playwright version and run the tests directly; the image is the cache.