Skip to content
compiler.dev

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 workers in 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 chromium fetches 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.

Run this Playwright tests on compiler.dev 200 free minutes

Try for free