PDFPipe

CI runners / GitHub Actions

Playwright on GitHub Actions: a browser download on every run

The workflow works and a large part of its duration is spent downloading browsers before anything is tested.

What is actually wrong

Runners are ephemeral, so the browser cache starts empty every time. The install command downloads several hundred megabytes per run, and by default it downloads three engines rather than the one being used.

What GitHub Actions gives you to work with

an ephemeral runner with a lot of memory, a browser already installed, and a job that ends when the workflow does.

  • Packaging: no packaging step at all. Anything can be installed at the start of the job, which is exactly why this environment misleads people about what production will do.
  • Filesystem: fully writable, and discarded when the job ends.
  • Processes: full control, with a maximum job duration measured in hours. Nothing is shared between runs, so every job pays every setup cost again.

The fix, if you are keeping Playwright

Install only the browser you need, and cache the download keyed on the library version so the cache is invalidated exactly when the browser changes and not otherwise.

yaml
- name: Cache browsers
  uses: actions/cache@v4
  with:
    path: ~/.cache/ms-playwright
    # Keyed on the lockfile: the cache is invalid exactly when the
    # library version, and therefore the browser build, changes.
    key: playwright-${{ hashFiles('package-lock.json') }}

- run: npx playwright install --with-deps chromium

What still hurts after that

The system dependency install still runs on a cache hit, because it installs apt packages rather than browsers. Caching makes the workflow faster and does nothing about the underlying point: this environment has root and a package manager, and the platform you deploy to almost certainly does not.

What Playwright is genuinely good at

a better API than Puppeteer for waiting on real page state, and a first-party dependency installer that knows which system packages the browser needs. That is worth keeping in view: the problem on this page is where it runs, not what it produces. Playwright is a browser automation library that installs its browsers outside node_modules, into a shared cache directory, at install time. It needs the same browser and shared libraries as Puppeteer, plus an install step that runs separately from the package install and writes to a location your deployment probably does not copy.

The other shape of the answer

Every fix above keeps the renderer inside your deployment, which means the constraint stays yours: the package size, the shared libraries, the fonts, the memory, the patching. The alternative is an HTTP call from GitHub Actions to something that already has a browser in it, which turns all of the above into a request and a response. That is what this API is, and it is the reason these pages exist. Whether that trade is right depends on whether rendering is a thing you want to operate.

Frequently asked

Can Playwright run on GitHub Actions at all?

Install only the browser you need, and cache the download keyed on the library version so the cache is invalidated exactly when the browser changes and not otherwise. Whether that is worth doing is a separate question from whether it is possible, and the section above on what still hurts is the honest answer to it.

Why does it work locally?

Because a development machine has root, a package manager, several gigabytes of memory, a full font set and no packaging limit. Every one of those is a thing GitHub Actions constrains. A renderer that works locally and fails on deploy has not regressed: it has met the first environment that does not give it everything.

Related failures

The same platform with a different renderer, and the same renderer on other platforms.

If the render moves off this platform, none of the above is your problem to keep solving. 100 free documents a month, and a playground that needs no signup.