{"slug": "the-playwright-github-actions-setup-that-actually-runs-fast", "title": "The Playwright + GitHub Actions setup that actually runs fast", "summary": "A developer published a Playwright and GitHub Actions configuration that keeps a browser test pipeline under five minutes on a single runner without sharding. The setup caches Playwright browser binaries in ~/.cache/ms-playwright keyed on the package lockfile, installs only system dependencies on cache hits, enables fullyParallel with CI-scaled workers, and runs Chromium-only on pull requests while testing all browsers on merges to main. The author reports the baseline workflow took 3 minutes 18 seconds on roughly 40 lightweight tests before these changes.", "body_md": "*This article was originally published on the* *Endform blog**.*\n\nBy the end of this you'll have a Playwright pipeline that gives useful feedback on every PR and stays under five minutes on a single runner, no sharding required. You need a Playwright suite, a GitHub repo, and the default Actions workflow that `npm init playwright@latest` generated. Two changes carry most of the weight: caching the browser binaries and fixing parallelism. Both are config edits you can land in an afternoon.\n\nHere's the complete setup first, then the reasoning behind each piece.\n\nTwo files. Worker count, `fullyParallel`, and the reporters go in `playwright.config.ts`. Caching, the Chromium-on-PR split, and the artifact upload go in the workflow YAML.\n\n`playwright.config.ts`:\n\n``` js\ntypescript\n\nimport { defineConfig, devices } from \"@playwright/test\";\n\nexport default defineConfig({\n  testDir: \"./tests\",\n  fullyParallel: true, // parallelize tests within a file, not just across files\n  forbidOnly: !!process.env.CI, // fail the build if a stray test.only is committed\n  retries: process.env.CI ? 2 : 0, // retry flaky tests in CI only\n  workers: process.env.CI ? \"50%\" : undefined, // scale workers to the runner; measure and raise from here\n  reporter: [\n    [\"html\"], // full report, uploaded as an artifact\n    [\"github\"], // inline annotations on the run summary\n  ],\n  use: {\n    trace: \"on-first-retry\", // capture a trace when a test retries\n  },\n  projects: [\n    { name: \"chromium\", use: { ...devices[\"Desktop Chrome\"] } },\n    { name: \"firefox\", use: { ...devices[\"Desktop Firefox\"] } },\n    { name: \"webkit\", use: { ...devices[\"Desktop Safari\"] } },\n  ],\n});\n```\n\n`.github/workflows/playwright.yml`:\n\n```\nyaml\n\nname: Playwright Tests\non:\n  pull_request:\n    branches: [main]\n  push:\n    branches: [main]\n\njobs:\n  test:\n    timeout-minutes: 60\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v7\n\n      - uses: actions/setup-node@v7\n        with:\n          node-version: lts/*\n          cache: \"npm\" # caches ~/.npm, the safe npm cache, not node_modules\n\n      - name: Install dependencies\n        run: npm ci\n\n      # Cache the browser binaries. The lockfile is a proxy for the\n      # Playwright version, so it usually turns over when you update Playwright.\n      - name: Cache Playwright browsers\n        id: playwright-cache\n        uses: actions/cache@v6\n        with:\n          path: ~/.cache/ms-playwright\n          key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }}\n\n      # Cache miss: download browsers and system libraries together\n      - name: Install browsers and system dependencies\n        if: steps.playwright-cache.outputs.cache-hit != 'true'\n        run: npx playwright install --with-deps\n\n      # Cache hit: binaries are restored, but system libraries still need installing\n      - name: Install OS dependencies for browsers\n        if: steps.playwright-cache.outputs.cache-hit == 'true'\n        run: npx playwright install-deps\n\n      # Chromium only on PRs for fast feedback; the full browser set on merges to main\n      - name: Run Playwright tests\n        run: |\n          if [ \"${{ github.event_name }}\" = \"pull_request\" ]; then\n            npx playwright test --project=chromium\n          else\n            npx playwright test\n          fi\n\n      # Upload the report whether tests pass or fail, so failures are never lost\n      - name: Upload Playwright report\n        uses: actions/upload-artifact@v7\n        if: ${{ !cancelled() }}\n        with:\n          name: playwright-report\n          path: playwright-report/\n          retention-days: 14\n```\n\nDrop both in, push, and the first run populates the cache while every run after it skips the download. The rest of this post explains why each block earns its place.\n\nThe default workflow from `npm init playwright@latest` works, it just spends most of its time on setup and a serial test run. On a public repo with about 40 lightweight tests, the baseline came in at 3m 18s. Treat that as a fixed number for comparing fixes, not a prediction of your own run times.\n\nOnly two steps mattered. `Install Playwright Browsers` took 42 seconds because it downloads Chromium, Firefox, and WebKit plus their system libraries on every single run, whether or not those browsers changed. `Run Playwright tests` took 2m 25s, and that number is about parallelism. The generated config pins CI to one worker:\n\n```\ntypescript\n\nworkers: process.env.CI ? 1 : undefined,\n```\n\nSo the suite runs close to one test at a time even with idle cores. Checkout, Node setup, and `npm ci` came in at a second or two each and aren't worth touching.\n\nTwo independent bottlenecks: repeated download overhead, and serial execution. Here's each fix.\n\nPlaywright installs its browsers to `~/.cache/ms-playwright`, which on the Linux runner resolves to `/home/runner/.cache/ms-playwright`. That directory is what you cache. The binaries only change when your Playwright version changes, so keying the cache to a hash of `package-lock.json` is a convenient proxy. Updating Playwright turns the cache over, though any unrelated dependency edit does too, which forces the occasional slow run you didn't strictly need.\n\nOne thing most guides skip: the cache stores the browser binaries but not the OS libraries they need, because those get installed system-wide with apt and never land in `~/.cache/ms-playwright`. On a cache hit the binaries are present but the libraries are missing, and tests fail with `Host system is missing dependencies to run browsers`. So you install the libraries on a cache hit too, just without the download:\n\n```\nyaml\n\nsteps:\n  - name: Cache Playwright browsers\n    id: playwright-cache\n    uses: actions/cache@v6\n    with:\n      path: ~/.cache/ms-playwright\n      key: ${{ runner.os }}-playwright-${{ hashFiles('package-lock.json') }}\n\n  - name: Install Playwright browsers and system dependencies\n    if: steps.playwright-cache.outputs.cache-hit != 'true'\n    run: npx playwright install --with-deps\n\n  - name: Install OS dependencies for browsers\n    if: steps.playwright-cache.outputs.cache-hit == 'true'\n    run: npx playwright install-deps\n```\n\nThe `id` on the cache step is what lets the two install steps read `cache-hit` and pick a branch. On a cache miss, `playwright install --with-deps` downloads browsers and libraries together. On a cache hit, `playwright install-deps` runs instead, every time, installing only the libraries. It's fast because there's no download, just a dependency check apt has already satisfied. Each job starts from a clean machine, so this isn't a one-off step you skip later.\n\nOne alternative skips caching: run the job inside the official `mcr.microsoft.com/playwright` image, which ships with browsers and OS packages preinstalled. You're trading the browser download for an image pull rather than removing it. It's a common choice for visual regression work where consistent rendering matters, but for most suites the cache is lighter.\n\nThe 2m 25s test run is Playwright doing exactly what the config told it to. Two changes fix it.\n\n`fullyParallel: true` lets tests within a single file run across workers. By default Playwright parallelizes separate files but runs tests inside one file in order, so a suite concentrated in a few large files leaves most workers idle. The second change replaces the hard one-worker cap with a percentage:\n\n```\ntypescript\n\nexport default defineConfig({\n  fullyParallel: true, // top-level, applies to every project\n  workers: process.env.CI ? \"50%\" : undefined, // top-level, not inside a project\n  use: {/* ... */},\n  projects: [/* chromium, firefox, webkit */],\n});\n```\n\nBoth keys sit at the top level of `defineConfig`, alongside `use` and `projects`, not nested inside any one project.\n\nOn the worker count, one assumption gets repeated: that GitHub's free runners are 2-core. That's true for private repos, but public repos now get 4-core Linux runners for free. On a 2-core runner `undefined` resolves to one worker, so the default is effectively serial. On a 4-core runner the same `undefined` gives you two workers out of the box.\n\n| Runner | Cores | 50% gives you | Worth trying | \n|---|---|---|---|\n| Private repo, ubuntu-latest | 2 | 1 worker | Set to 2 explicitly | \n| Public repo, ubuntu-latest | 4 | 2 workers | 75% or 100%, then measure | \n| Larger runner | 8 | 4 workers | 75% and up, more headroom | \n\nStart at 50%, look at wall-clock time on your suite, and raise it in steps until the run stops getting faster or the [tests start going flaky](https://endform.dev/blog/playwright-flaky-tests). There's no single correct number, since it depends on core count, how heavy your tests are, and how much memory each browser instance eats. The thing not to do is push workers well above your core count. Once workers contend for the same cores and RAM, you trade a faster run for memory pressure and intermittent failures that didn't exist serially.\n\nWith `fullyParallel` on and the cap lifted, the test run on a 4-core runner drops to roughly half, and the install step is already cached. Those two fixes together pull the whole job under five minutes.\n\nThe baseline was hiding something. The default config runs your tests against Chromium, Firefox, and WebKit, so those 40 tests were actually 120 test runs on every push. A good share of that 2m 25s was the same assertions executing twice more against browsers that rarely surface a bug the first one missed.\n\nIf your product doesn't need every browser verified on every change, tier it by event. Run Chromium on every PR, where the feedback loop needs to be fast and most rendering and logic bugs show up. Save the full three-browser sweep for merges to main or a nightly schedule. It's a single flag switched on the triggering event:\n\n```\nyaml\n\non:\n  pull_request:\n  push:\n    branches: [main]\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    steps:\n      # checkout, setup-node, cache, and install from the earlier sections\n      - name: Run Playwright tests\n        run: |\n          if [ \"${{ github.event_name }}\" = \"pull_request\" ]; then\n            npx playwright test --project=chromium\n          else\n            npx playwright test\n          fi\n```\n\nOn a PR this runs Chromium alone. On a push to main it falls through to the full set. This keeps everything in one job. Local runs are unaffected, since the conditionals key off the CI event.\n\nIf you'd rather run the three browsers as separate parallel jobs on main, a conditional matrix does that:\n\n```\nyaml\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    strategy:\n      fail-fast: false\n      matrix:\n        project: ${{ github.event_name == 'push'\n          && fromJSON('[\"chromium\", \"firefox\", \"webkit\"]')\n          || fromJSON('[\"chromium\"]') }}\n    steps:\n      # same setup steps\n      - run: npx playwright test --project=${{ matrix.project }}\n```\n\nA push to main fans out into three jobs, one per browser; a PR collapses to a single Chromium job. It stays bounded at three jobs, so it doesn't drift into open-ended job-splitting.\n\nOne caveat on WebKit: the build Playwright runs on Linux tracks WebKit's main development branch, often ahead of what Apple ships in Safari, and it lacks Apple's platform integrations. A green Linux WebKit run is a good signal for layout and JavaScript bugs, but it's not proof the page looks right in Safari. When that distinction matters you need macOS runners, which is a separate decision from this workflow.\n\nPlaywright writes an HTML report on every run, but a CI runner is torn down when the job ends, so by default that report vanishes and you never see it. Teams usually discover this on their first failing run, when they go looking for the detail behind a red X and find nothing left to open.\n\nUpload it as an artifact before the job exits:\n\n```\nyaml\n\nsteps:\n  - name: Upload Playwright report\n    uses: actions/upload-artifact@v7\n    if: ${{ !cancelled() }}\n    with:\n      name: playwright-report\n      path: playwright-report/\n      retention-days: 14\n```\n\nThe `if: ${{ !cancelled() }}` is doing real work. Without it, the step only runs when everything before it passed, which is backwards, since the run you most want the report for is the one that failed. This uploads whether tests passed or failed and skips only on a manual cancel. Use `retention-days: 14` for active development and `30` on release branches.\n\nThe artifact gives you the full report but costs a few clicks. You can't open the HTML file directly, since it loads its data over HTTP, so you download the zip, unzip it, and run `npx playwright show-report path/to/playwright-report`. Fine when you need a trace, more friction than most failures deserve.\n\nThat's where a second reporter comes in. The `github` reporter writes failures as inline annotations in the Actions UI, so a failed assertion shows up on the run summary and against the offending line in the diff, no download in the loop. Add it alongside the HTML reporter rather than replacing it:\n\n```\ntypescript\n\nexport default defineConfig({\n  reporter: [[\"html\"], [\"github\"]],\n  // ... rest of your config\n});\n```\n\nNow a failed run annotates the summary with what broke and where, and the full HTML report is still attached for the times you need the trace, screenshots, and step timeline.\n\nIf your tests need a base URL or an API token, pass it through the test step with an `env` block. Non-sensitive values can go straight in the workflow; anything secret goes in a repository secret and gets referenced:\n\n```\nyaml\n\nsteps:\n  - name: Run Playwright tests\n    env:\n      BASE_URL: https://staging.example.com\n      API_TOKEN: ${{ secrets.API_TOKEN }}\n    run: # ... as above\n```\n\nSharding splits your suite across several CI jobs running in parallel, each handling a slice, with a final step merging the reports. It's the standard answer to a slow Playwright suite, and it works, but it solves a problem you don't have until a single runner genuinely can't keep up.\n\nThe decision rule: start with workers and `fullyParallel`, tune the worker count, and measure your best single-runner time. If you've maxed that and still need more speed, try a bigger runner before sharding. An 8 or 16-core GitHub-hosted runner gives you more workers without the merge-reports step or shard-count maintenance. Only reach for sharding when one runner, even a larger one, can't finish inside your PR review window.\n\n| Suite size | Single-runner time | What to do | \n|---|---|---|\n| Under 50 tests | Well under 5 min | Workers and fullyParallel are plenty | \n| 50–200 tests | Around 3 to 5 min | Move to a bigger runner before you shard | \n| Over 200 tests, or 10+ min | Past your PR window | Shard, or move the run off your own CI | \n\nRun time is what matters here, not test count. A suite of heavy end-to-end flows hits the ceiling sooner than the same number of light page checks. Sharding buys wall-clock time; it costs more CI minutes, a merge-reports step, and a shard count you revisit as the suite grows. That's maintenance you take on deliberately, not a default the moment a run feels slow.\n\n**Installing browsers without `--with-deps`.** `npx playwright install` alone fetches binaries but not the system libraries, and runners don't ship those. Tests fail with a \"Host system is missing dependencies\" error that says nothing about the cause. Use `--with-deps` on a cache miss and `install-deps` on a cache hit.\n\n**Caching `node_modules` instead of the npm cache.** A restored `node_modules` can carry platform-specific or partially built dependencies that don't match the runner, surfacing as subtle failures. Cache `~/.npm` by setting `cache: 'npm'` on the setup-node step and let `npm ci` rebuild cleanly each run.\n\n**Setting workers too high for the runner.** More workers stop helping once they outnumber the cores, and past that point the browser instances contend for CPU and memory, trading speed for flakiness. Scale to the runner and raise only as far as your measurements stay stable.\n\n**Forgetting the `github` reporter.** Without it, a failed run tells you something broke but not what. Adding `['github']` alongside `['html']` puts the failure inline on the summary.\n\nA [fast Playwright pipeline](https://endform.dev/blog/the-fastest-playwright-runner) on GitHub Actions doesn't require sharding. Caching the browser binaries and tuning parallelism for your runner are both afternoon-sized config changes, and they pay off on every PR rather than once. The single-runner setup holds for a good while as a suite grows, and outgrowing it is a scaling decision to make on purpose when your numbers cross the line, not evidence something was done wrong.\n\nIf your suite has crossed that line and you don't want to own shard counts, matrix jobs, and report merging, [Endform](https://endform.dev) gives you the same parallelism without the CI plumbing.", "url": "https://wpnews.pro/news/the-playwright-github-actions-setup-that-actually-runs-fast", "canonical_source": "https://dev.to/jakobnorlin/the-playwright-github-actions-setup-that-actually-runs-fast-317i", "published_at": "2026-09-15 14:53:37+00:00", "updated_at": "2026-09-15 15:21:02.203852+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Playwright", "GitHub Actions", "Endform", "Chromium", "Firefox", "WebKit", "Node.js"], "alternates": {"html": "https://wpnews.pro/news/the-playwright-github-actions-setup-that-actually-runs-fast", "markdown": "https://wpnews.pro/news/the-playwright-github-actions-setup-that-actually-runs-fast.md", "text": "https://wpnews.pro/news/the-playwright-github-actions-setup-that-actually-runs-fast.txt", "jsonld": "https://wpnews.pro/news/the-playwright-github-actions-setup-that-actually-runs-fast.jsonld"}}