Playwright architecture, selector reliability, and advanced interaction patterns.

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.

Where Playwright browser binaries live on a CI runner The npm install step populates node_modules with library code only, while the install command downloads engine builds into a separate versioned cache directory that the cache action restores. CI job starts npm ci installs the library node_modules/@playwright/test no browser binaries here npx playwright install downloads engine builds hundreds of MB per cold job actions/cache restore step runs before install ~/.cache/ms-playwright chromium-1148 firefox-1466 webkit-2070 ffmpeg-1011 build numbers are pinned by the installed Playwright version
Browsers never live in node_modules, so a node_modules cache does nothing for them — the versioned directory beside it is the artifact worth restoring.

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'] } },
  ],
});
What a cache hit and a cache miss each have to run A decision point on the exact cache key splits into a miss branch that downloads browsers and system libraries and a hit branch that installs only the operating-system dependencies. cache key inputs runner OS + runner image tag + resolved Playwright version exact hit? miss: download everything playwright install --with-deps then save under the exact key hit: binaries restored playwright install-deps system libraries only no yes
Both branches end with a runnable browser, but only the miss branch pays for the download — the hit branch still has to restore system libraries the cache never held.

Step-by-step fix

  1. 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 — set PLAYWRIGHT_BROWSERS_PATH to a directory under the project root before any install step, and cache that instead. Setting it to 0 forces installation into node_modules, which couples two caches with different invalidation rules and is worth avoiding.
  2. 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.
  3. 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.
  4. Install operating-system dependencies on every job. The cache holds browser binaries, not the apt packages they link against, and a fresh runner has none of them. Run npx playwright install-deps on the hit branch. Skipping this is what produces Host system is missing dependencies to run browsers and errors naming libnss3.so or libgbm.so.1 on a job whose cache restored perfectly.
  5. 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-shell skips 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.
  6. Skip restore-keys and 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.
  7. 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.

Job timeline with a cold cache versus a warm cache Two horizontal timelines show that a cold job spends about ninety seconds downloading browsers before tests begin, while a warm job replaces that segment with a short restore. cold cache npm ci browser download tests warm cache npm ci restore tests 0s 60s 120s 180s the warm job finishes before the cold job has launched its first browser
The saving is the download segment; everything else in the job is unchanged, which is why the install step duration is the metric to watch.

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.

Back to overview