How Do I Run Browser Tests Headless in GitHub Actions?

Run browser tests headless in GitHub Actions by installing the browser system libraries, restoring the browser cache, and sizing worker count to runner memory.

Last verified:

Run browser tests headless in GitHub Actions by launching the browser in its headless mode with the system libraries it links against already installed, then giving the job enough memory for the worker count you set. Browser workers are memory hungry, so the runner size decides how many can run at once, and a job that fits four workers into a 4 vCPU machine with no memory headroom is the job that dies mid-suite with a closed target.

Answer

Three things have to be true before a headless run is reliable.

The browser has to be in headless mode. Playwright runs headless by default and only opens a window when headless: false or --headed is set, so the usual work here is making sure nobody left a headed override in the config. Tools that still require a display, such as some Electron-based runners, work under xvfb-run and need no other change.

The system libraries have to be present. A browser binary links against fonts, audio, graphics, and X client libraries that a plain runner image does not carry for every browser build. npx playwright install --with-deps chromium installs both the browser and its apt dependencies, and the Playwright continuous integration docs document the same step for every supported distribution.

The job has to have memory for the workers. Each worker is a separate process driving its own browser, and a headless browser holds roughly the same memory as a headed one. This is the constraint most teams get wrong, because CPU shows up as a slow suite while memory shows up as a crash.

Here is the memory model against the Linux x64 ladder. Reserve about 4 GB for the operating system, the Node process, and any service the tests talk to inside the job, then divide what is left by the per-worker footprint. Measure your own footprint before trusting the number; the table below uses 1.5 GB per worker as a conservative placeholder.

Runner labelvCPUMemoryLeft after a 4 GB reserveWorkers at 1.5 GB eachCap at 0.75 workers per vCPUSetting
warp-ubuntu-latest-x64-2x28GB4GB21--workers=1
warp-ubuntu-latest-x64-4x416GB12GB83--workers=3
warp-ubuntu-latest-x64-8x832GB28GB186--workers=6
warp-ubuntu-latest-x64-16x1664GB60GB4012--workers=12

Shapes and storage come from the cloud runners documentation, where every Linux size carries a 150GB SSD. Browser suites belong on Linux x64 because the browser builds and their system libraries are best supported there.

Read the table as two columns competing for the smaller answer. At 1.5 GB per worker, CPU binds first at every row, which is the shape you want. Raise the footprint to 4 GB for tests that drive a heavy single-page application and the 4 vCPU row drops to 3 workers, where memory and CPU bind together and the next size up becomes the honest answer.

Detail

A workflow that installs, restores, and sizes

This job installs the system libraries, restores the browser binaries from cache, installs the fonts the application needs, and sets a worker count that matches the 8 vCPU label it runs on.

name: browser-tests
on:
  pull_request:

jobs:
  headless:
    runs-on: warp-ubuntu-latest-x64-8x
    steps:
      - uses: actions/checkout@v4

      - uses: WarpBuilds/setup-node@v6
        with:
          node-version: 22
          cache: npm

      - run: npm ci

      - name: Read the Playwright version
        id: pw
        run: |
          echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT"

      - name: Restore browser binaries
        id: browsers
        uses: WarpBuilds/cache@v1
        with:
          path: ~/.cache/ms-playwright
          key: ${{ runner.os }}-browsers-${{ steps.pw.outputs.version }}

      - name: Install browsers and system libraries
        if: steps.browsers.outputs.cache-hit != 'true'
        run: npx playwright install --with-deps chromium

      - name: Install system libraries only
        if: steps.browsers.outputs.cache-hit == 'true'
        run: npx playwright install-deps chromium

      - name: Install the fonts the application renders with
        run: |
          sudo apt-get update
          sudo apt-get install -y fonts-liberation fonts-noto-color-emoji

      - run: npx playwright test --workers=6

Two details carry the run. The cache key hashes the installed Playwright version because browser builds are pinned to the release, and a cache hit still needs install-deps because the apt packages live in system directories outside ~/.cache/ms-playwright. WarpBuilds/cache is a drop-in replacement for actions/cache@v4 and is enabled by default on WarpBuild runners, and the caching documentation covers the scoping rules: entries are keyed by key, version, and branch, and expire 7 days after last use. The key-by-version pattern is worked through in full on caching Playwright browsers in GitHub Actions.

One container-only caveat. Headless Chromium uses shared memory heavily, and a container with the default 64 MB /dev/shm crashes tabs under load. On a runner virtual machine /dev/shm is sized from RAM, so the flag --disable-dev-shm-usage belongs only in jobs that run the browser inside a container.

The two flaky sources in headless runs

Viewport differences. A headed browser inherits the size of whatever window you had open, while a headless run takes the tool default, and Playwright's default viewport is 1280 by 720 for every project unless the viewport option is set. A layout that breaks below 1280 pixels never appears on a maximized laptop and fails on every GitHub Actions run, and a test that scrolls to an element off the bottom of a 720 pixel viewport fails the same way. Set viewport explicitly per project, and set deviceScaleFactor too when screenshots are compared, so the local run and the GitHub Actions run describe the same window.

Missing fonts. The Linux x64 runner images carry the same tooling as GitHub-hosted runners, and the installed software lists for those images are published per release in the actions/runner-images repository. Any font outside that list falls back to whatever fontconfig picks, which moves text metrics by a few pixels, changes where a word wraps, and fails a screenshot comparison or a click at a computed coordinate. Emoji are the common case, since a run without an emoji font renders empty boxes. Install the exact font packages in the workflow, as the job above does, or vendor the font files into the repository and point fontconfig at them.

What the size costs

Sizing for memory usually means moving up a label, so price the move before making it. Take a browser suite that runs 600 times a month as one job of 12 minutes. On warp-ubuntu-latest-x64-16x that is 7,200 minutes at $0.032 per minute, or $230.40 a month. The same minutes on the 16-core Linux larger runner at $0.042 per minute come to $302.40, a difference of $72.00.

Stated as list-price arithmetic: warp-ubuntu-latest-x64-16x (16 vCPU, 64 GB) costs $0.032 per minute against $0.042 per minute for the 16-core Linux larger runner (16 vCPU, 64 GB): 24 percent lower list price. GitHub list price checked on 2026-08-13 at the GitHub Actions minute multipliers reference.

The guide to sizing runners for Node test suites covers the same arithmetic for the rest of a Node pipeline.

Observability reports max memory utilization per job, which turns the placeholder in the table above into your number. The full sharding, reporting, and bottleneck playbook lives on Playwright test suites on GitHub Actions.

Do I need xvfb to run browser tests in GitHub Actions?

Headless Chromium and Firefox builds render without an X server, so xvfb is only needed by tools that still demand a display, such as Electron-based test runners. Wrap the command in xvfb-run for those, and leave it out everywhere else. What every browser does need is the apt dependency set, which npx playwright install --with-deps installs per the Playwright continuous integration docs.

How many browser workers fit on a GitHub Actions runner?

Subtract a 4 GB reserve from runner memory, divide by the per-worker footprint you measured, then cap the result at about 0.75 workers per vCPU. On warp-ubuntu-latest-x64-8x with 8 vCPU and 32 GB that lands at 6 workers. Sizes and memory for every label are in the cloud runners documentation, and the sharding side of the decision is on Playwright test suites on GitHub Actions.

Why do headless browser tests pass locally and fail in GitHub Actions?

Viewport and fonts are the two usual causes. The headless default viewport differs from the desktop window you test against locally, and the runner image ships a limited font set, so text metrics shift and screenshot comparisons fail. Pin the viewport in the config and install the font packages in the workflow. A third cause worth ruling out early is a cold browser cache adding a download to every job, which caching Playwright browsers in GitHub Actions removes.

Start with $10 in free credits

Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.