Speed Up Playwright in a Monorepo on GitHub Actions
Last updated
In a monorepo, Playwright is slow for reasons that a single-app project does not have. Every shard builds the app again, e2e runs on pull requests that never touched the web app, and each job installs the browser before it runs a single test. To speed it up, build once and share the build, run e2e only when the web app or something it depends on changed, then shard the tests and merge the reports.
This guide is about the monorepo layout. For the test-level basics (workers, storageState, waiting for the right thing) read Playwright tests slow first. For how sharding itself works, see test sharding in GitHub Actions.
Build once, test the build
The usual first version of a Playwright workflow shards the tests across four jobs, and each job installs dependencies, builds the app and then starts it. The build runs four times for the same commit. Build it in one job and hand the output to the shards:
jobs:
build:
runs-on: [self-hosted, Linux, compiler-ci] # or ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm --filter web... build
- uses: actions/upload-artifact@v4
with:
name: web-build
path: apps/web/dist
retention-days: 1
pnpm --filter web... build builds the web app and the workspace packages it depends on, and nothing else. The ... after the name means "this package and its dependencies". Each shard then downloads the build and serves it:
// apps/web/playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
fullyParallel: true,
reporter: process.env.CI ? [["blob"], ["list"]] : "list",
webServer: {
command: "pnpm exec vite preview --port 4173 --strictPort",
url: "http://localhost:4173",
reuseExistingServer: !process.env.CI,
},
});
Serve the production build, not a dev server. A dev server compiles files on demand while your tests wait. Use the equivalent for your framework, such as next start for Next.js. The artifact costs an upload and a download, so it pays off when the build takes longer than that. Set retention-days low, because nobody needs last week's build.
Run e2e only when the web app changed
In a monorepo most pull requests touch a part of the repository that your e2e suite does not exercise. Decide in a small first job whether the suite needs to run at all:
changes:
runs-on: ubuntu-latest
outputs:
web: ${{ steps.filter.outputs.web }}
steps:
- uses: actions/checkout@v6
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
web:
- 'apps/web/**'
- 'packages/ui/**'
- 'packages/api-client/**'
- 'pnpm-lock.yaml'
and gate the build and the shards on it with if: needs.changes.outputs.web == 'true'. The weakness of a path list is that you maintain it by hand: when apps/web starts to depend on a new package, someone has to add it. If your packages declare their dependencies properly, let pnpm work it out instead, as described in fastest CI for pnpm workspaces. Either way, run the whole e2e suite on pushes to main, so a skipped run on a pull request cannot hide a break for long.
Keep the filter inside the workflow, as a job, rather than in the workflow's paths: trigger. GitHub's docs say that when a whole workflow is skipped by a path filter, the checks it would have reported stay pending, so a pull request that requires them cannot be merged. A job skipped by if: reports success.
--only-changed is not a substitute
Playwright has --only-changed. Its help text says it runs only test files that changed between HEAD and a ref you give it, and that it needs Git:
npx playwright test --only-changed=origin/main
We tried it on a small project with Playwright 1.63. It selects a test file when the file itself changed, or when a file it imports changed. It does not know about your application. A pull request that changes app/page.ts, which no test file imports, selected zero tests. Your end-to-end tests drive the app through a browser, so almost all application changes look like that. Use --only-changed for quick feedback while writing tests, or for a pull request that only edits specs and test helpers, not as the safety net. It needs the base branch in the clone, so check out with fetch-depth: 0.
Shard and merge
With the build available as an artifact, the shard jobs only install, download and test:
e2e:
needs: build
runs-on: [self-hosted, Linux, compiler-ci]
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile --filter web...
- uses: actions/download-artifact@v4
with:
name: web-build
path: apps/web/dist
- run: pnpm --filter web exec playwright install --with-deps chromium
- run: pnpm --filter web exec playwright test --shard=${{ matrix.shard }}/4
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: blob-report-${{ matrix.shard }}
path: apps/web/blob-report
retention-days: 1
report:
needs: e2e
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v7
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile --filter web...
- uses: actions/download-artifact@v4
with:
pattern: blob-report-*
path: all-blob-reports
merge-multiple: true
- run: pnpm --filter web exec playwright merge-reports --reporter html ../../all-blob-reports
- uses: actions/upload-artifact@v4
with:
name: playwright-report
path: apps/web/playwright-report
retention-days: 7
pnpm install --filter web... installs only the web app and what it depends on, which keeps each shard's install short. Shard count is a trade between wall time and the fixed cost every shard pays (checkout, install, browser). The test sharding planner estimates it. With fullyParallel: true, Playwright splits at test level, which balances shards better than splitting by file.
Browsers and workers
- Playwright runs about half the logical CPU cores as workers by default. The CLI help says "default: 50%". On a 2 vCPU runner that is one worker; on 8 vCPU it is four. Raise it with
workersin the config or--workers=75%and compare total time and failures, because browsers are heavy and too many workers cause timeouts. playwright install --with-deps chromiumfetches one browser. Playwright's own docs note that caching browser downloads often saves little, since restoring a large cache can take as long as downloading it. Measure before you add a cache. Playwright also publishes container images with the browsers and system libraries already installed; use the image tag that matches the Playwright version in your lockfile.
What more CPU cores buy you
The test job is the part that scales with cores, because workers run browsers side by side. Build and install do not scale much. So a bigger machine helps the shard jobs and does little for build. More shards on small machines and fewer shards on big machines are two ways to buy the same parallelism, with different bills and different fixed costs per shard. Compare the total for your suite with the CI cost calculator.
To try your own suite, change one line to runs-on: [self-hosted, Linux, compiler-ci], or use comparison mode, which runs a copy of your jobs next to your current ones. See pricing, compare and our benchmarks. Related: Cypress tests slow, flaky e2e tests and the slow CI hub.
FAQ
How do I build the app once and share it between Playwright shards?
Build in one job, upload the output with actions/upload-artifact, and download it in each shard job before starting the web server with webServer in the Playwright config.
Does Playwright --only-changed run tests affected by app changes?
No. It selects test files that changed between HEAD and a ref, plus test files that import a changed file. Application code that your tests reach only through the browser is not tracked, so changing it alone can select zero tests.
How do I skip e2e tests when the web app did not change?
Add a first job that checks which paths changed, with a paths filter action or pnpm's changed-package selector, and gate the build and shard jobs on its output with if:. Do not use the workflow-level paths: trigger if the checks are required.
How many Playwright shards should a monorepo use?
Enough that the slowest shard is not much longer than the fixed cost of checkout, install and browser setup. Past that point more shards only cost more.
Related
- Speed Up a TypeScript Monorepo in GitHub ActionsSpeed up type checking and linting in a TypeScript monorepo on GitHub Actions: incremental tsc, project references, lint caches and parallel jobs.
- Add a CI Speed Badge to Your READMEShow how fast your CI is with a README badge: the median GitHub Actions run time for your repository, updated automatically. Free for public repos.
- Fastest CI for Bazel Monorepos on GitHub ActionsFast Bazel CI on GitHub Actions. Target determination for affected builds, remote cache flags, read-only PR uploads, sharding and a complete workflow.
- Fastest CI for Nx Monorepos on GitHub ActionsMake Nx CI fast on GitHub Actions. nx affected with nx-set-shas, Nx remote cache, task distribution, fetch-depth and a complete workflow.