Caching Browser Binaries for axe-core Scans in CI
This guide is part of Docker-Based Pipeline Execution, and it does exactly one thing: stop the accessibility job downloading a browser it already downloaded yesterday. On a cold run, fetching and extracting a Chromium bundle costs 25 to 40 seconds of the job’s wall clock and roughly 170 MB of egress, every run, on every branch. That is the single largest fixed cost in a containerised axe-core scan and the easiest one to remove — provided the cache key tracks the thing that actually changes the binary, and provided you can tell the difference between a cache that worked and a cache that reported a hit and then downloaded anyway.
Root Cause
Neither Playwright nor Puppeteer ships a browser inside its npm package. The package contains a driver and a version manifest; the browser itself is fetched on first use into a directory outside node_modules. Playwright uses $HOME/.cache/ms-playwright on Linux, overridable with PLAYWRIGHT_BROWSERS_PATH (and note that the special value 0 relocates browsers into node_modules, which changes what you have to cache). Puppeteer uses $HOME/.cache/puppeteer, overridable with PUPPETEER_CACHE_DIR or the cacheDirectory key in .puppeteerrc.cjs. axe-core itself is pure JavaScript and lives in node_modules, so the dependency cache already covers it — the expensive artifact is always the browser.
Inside a fresh CI container both of those directories are empty, so npx playwright install chromium re-fetches the full bundle. Teams then reach for their provider’s cache action and key it on something convenient rather than something correct. A key built from the branch name restores whatever that branch happened to write last, which means a driver upgrade quietly reuses the previous revision and the scan runs against a browser the driver was not tested against. A key built only from the lockfile hash is closer but still wrong in the other direction: the lockfile changes when any dependency moves, so an unrelated dependency bump throws away a perfectly good browser. The key has to contain both the resolved driver version and the lockfile hash, and nothing whose value is unrelated to the binary.
There is a second, quieter failure. A restored cache directory is not the same thing as a usable browser. The cache holds the browser bundle; it does not hold the system libraries that playwright install --with-deps installs through apt-get. Restore the cache into a container that never ran install-deps and Chromium fails at launch with error while loading shared libraries: libnss3.so, which reads like a broken image rather than a cache problem. And when the restored directory contains chromium-1140 while the pinned driver wants chromium-1148, playwright install simply downloads the missing revision, prints one line about it, and exits zero — the job stays green, the cache stays permanently stale, and the 30 seconds you thought you saved is still being spent every run.
Configuration
If the pipeline already builds a container image, the browser belongs in an image layer and the cache action is redundant. A layer is content-addressed and deduplicated by the registry, it is pulled once and reused by every job in the run, and — crucially — the same layer that holds the browser can hold the apt packages the browser links against, so the two can never drift apart. A restored cache directory can only ever hold half the problem.
The ordering below is what makes the layer stick. System dependencies come first because they change least; the browser download sits directly after npm ci and before any application code, so editing a component does not re-fetch 170 MB.
# syntax=docker/dockerfile:1.7
# Dockerfile.a11y — the browser is a layer, not a restored directory.
FROM node:20.18.1-bookworm-slim AS deps
ENV DEBIAN_FRONTEND=noninteractive \
PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
# install-deps ONLY: the apt packages Chromium links against. This layer is
# invalidated by the driver's minor version, not by the lockfile, so it survives
# most dependency bumps. A restored CI cache never contains any of this.
RUN npx --yes playwright@1.47.2 install-deps chromium \
&& rm -rf /var/lib/apt/lists/*
FROM deps AS browser
WORKDIR /app
# Manifests only. npm ci and the download below are one cache generation.
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# No --with-deps here: the apt work already happened in the deps stage, and
# repeating it would put an apt layer above the npm layer where it churns.
RUN npx playwright install chromium \
&& chmod -R a+rX /ms-playwright
FROM browser AS scan
WORKDIR /app
# Application code last: a source edit rebuilds this layer and nothing below it.
COPY scripts ./scripts
COPY dist ./dist
RUN useradd --create-home --uid 10001 scanner
USER scanner
ENTRYPOINT ["node", "scripts/axe-scan.mjs"]
When the pipeline does not build an image — a plain Node job running axe-core through Playwright directly on the runner — a cache action is the right tool, with two rules. Resolve the driver version at runtime rather than hardcoding it, and keep install-deps outside the cache guard so the system libraries are present whether or not the browser was restored.
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
# Resolve the version the lockfile actually installed, so the key cannot lie.
- id: driver
run: |
echo "version=$(node -p "require('playwright/package.json').version")" \
>> "$GITHUB_OUTPUT"
# Namespace, arch, driver version, lockfile hash. No branch, no run id.
- id: key
env:
DRIVER: ${{ steps.driver.outputs.version }}
LOCK: ${{ hashFiles('package-lock.json') }}
run: |
echo "cache=pw-${RUNNER_OS}-${RUNNER_ARCH}-${DRIVER}-${LOCK}" >> "$GITHUB_OUTPUT"
- name: Restore the browser bundle
id: browser-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: ${{ steps.key.outputs.cache }}
# No restore-keys: a partial match here means the wrong revision.
- name: System libraries for Chromium
# Always runs: these apt packages are NOT in the cached directory.
run: npx playwright install-deps chromium
- name: Download the browser only when the cache did not supply it
if: steps.browser-cache.outputs.cache-hit != 'true'
run: npx playwright install chromium
- name: Fail if a reported hit did not actually supply the browser
run: bash scripts/assert-browser-cache.sh
- name: Run the axe-core scan
run: node scripts/axe-scan.mjs
Puppeteer needs the same treatment with different paths. Pin the cache directory into the repository so the value is identical on a laptop, in a container and on a runner, rather than depending on whatever $HOME happens to be for the CI user — a detail that bites when a job switches from the default user to a non-root one and the cache silently moves.
// .puppeteerrc.cjs — one cache location for every environment.
const { join } = require('node:path');
module.exports = {
// Relative to the project root, so PLAYWRIGHT/HOME differences stop mattering.
cacheDirectory: join(__dirname, '.cache', 'puppeteer'),
// Never skip the download in CI: a missing browser must fail at install time,
// not at scan time in the middle of a WCAG 2.2 SC 1.4.3 contrast check.
skipDownload: false,
};
Validation
The guard below is the part most pipelines are missing. playwright install --dry-run prints the install location the driver expects for the pinned version, which is the only authoritative answer to “is the right revision on disk”. Comparing that path against the filesystem turns a silent re-download into a failed step with an actionable message.
#!/usr/bin/env bash
# scripts/assert-browser-cache.sh
# Fails when the pinned driver's expected browser directory is absent, which is
# the signal that a "cache hit" restored the wrong revision.
set -euo pipefail
# --dry-run resolves the revision from the installed driver and prints, e.g.
# Install location: /home/runner/.cache/ms-playwright/chromium-1148
expected="$(npx playwright install --dry-run chromium \
| awk -F': ' '/Install location/ {print $2; exit}')"
if [[ -z "$expected" ]]; then
echo "could not resolve the expected browser path from the driver" >&2
exit 1
fi
if [[ ! -x "$expected/chrome-linux/chrome" ]]; then
echo "STALE CACHE: driver wants $expected but it is not on disk" >&2
echo "present instead:" >&2
ls -1 "$(dirname "$expected")" >&2
exit 1
fi
echo "browser cache OK: $expected"
du -sh "$expected" # ~170 MB for a chromium bundle; a few KB means a bad restore
Then prove the scan actually used it. A run that downloaded nothing prints no download progress, and the scan’s own report should record the browser build so a reviewer can tie a violation list to a revision.
// scripts/axe-scan.mjs — proves the cached engine renders and reports.
import { chromium } from 'playwright';
import AxeBuilder from '@axe-core/playwright';
// Throws instead of downloading if the bundle is missing, so a cache miss is a
// loud failure here rather than a silent 30-second tax.
const browser = await chromium.launch({ chromiumSandbox: true });
console.log(`engine: ${browser.version()}`); // e.g. 130.0.6723.31
const page = await browser.newPage();
await page.goto(process.env.SCAN_URL ?? 'http://app:8080/checkout');
await page.getByRole('main').waitFor({ state: 'visible' });
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag22aa'])
.analyze();
const serious = results.violations.filter(
(v) => v.impact === 'critical' || v.impact === 'serious',
);
console.log(`${serious.length} blocking of ${results.violations.length} total`);
await browser.close();
process.exit(serious.length > 0 ? 1 : 0); // non-zero fails the gate
Edge Cases and Conditional Guards
- Multiple engines in one job. Each engine is a separate directory inside the cache path, so a job that scans Chromium and WebKit needs both installed before the guard runs, and the guard needs a loop over the engines rather than a single hardcoded
chromium. Restrict the engine list to what the accessibility gate actually asserts on; a second engine roughly doubles both the cache size and the restore time for a small increase in real coverage. - Non-root containers and
$HOME.~/.cache/ms-playwrightresolves against the current user’s home, so a cache populated as root and then read as uid 10001 is simply not there. SetPLAYWRIGHT_BROWSERS_PATHto an absolute path outside any home directory andchmod -R a+rXit, which is why the Dockerfile above installs into/ms-playwright. - Sharded and matrix jobs. Parallel shards started at the same moment all miss the cache together on the first run after a driver bump, so the download cost is multiplied by the shard count exactly once. Warm the cache in a single upstream job — or build the image once and let the shards pull it — before fanning out, as described in sharding axe-core scans across parallel CI jobs.
Pipeline Impact
Caching changes nothing about what the gate decides. The scan’s exit code still comes from the violation impacts, the artifact is unchanged, and the rule set configured in the axe-core configuration and setup guide behaves identically against a restored binary and a freshly downloaded one — the bytes are the same bytes. What changes is the job’s position in the critical path. A scan that finishes in 70 seconds instead of 115 is a scan reviewers wait for; a scan that takes three minutes is a scan somebody eventually moves to a nightly schedule, at which point it stops gating anything.
The second effect is failure attribution. Once the guard step exists, “the browser was re-downloaded” is its own red step with its own message, instead of an invisible 30 seconds inside a step called Install browser. That matters when a driver upgrade lands: the guard fires on the first run after the bump, confirms the cache correctly invalidated, and then goes quiet — which is exactly the behaviour you want from a cache you are trusting to feed a required status check.
Common Pitfalls
- A branch or run-id in the key. Both make the cache either unshareable or wrong. The key should be a pure function of the runner architecture, the driver version and the lockfile.
restore-keysas a fallback. A prefix match restores a different revision and reports a hit, which is precisely the stale-cache case the guard exists to catch. Leave it out for browser bundles.- Caching
node_modulesand expecting the browser. The bundle lives outsidenode_modulesunlessPLAYWRIGHT_BROWSERS_PATH=0is set, and that setting has its own consequence: the browser now invalidates on every dependency change. - Running
install --with-depsbehind the cache-hit guard. On a hit, the apt packages are skipped and Chromium fails to loadlibnss3.so. Splitinstall-depsout and run it unconditionally, or bake it into the image. - Never rotating the namespace. Old revisions accumulate in the cache backend and count against the repository’s cache quota, evicting the entry you actually wanted. Bump the
pwprefix once a quarter. - Trusting
cache-hit: true. It reports that a key matched and a tarball was extracted, not that the browser the driver wants is present and executable. Assert the path.
FAQ
Which directory does each tool actually download into?
Playwright uses $HOME/.cache/ms-playwright on Linux and honours PLAYWRIGHT_BROWSERS_PATH, where the literal value 0 means “inside node_modules”. Puppeteer uses $HOME/.cache/puppeteer and honours PUPPETEER_CACHE_DIR or cacheDirectory in .puppeteerrc.cjs. axe-core needs no cache of its own beyond the normal dependency cache, because it is JavaScript that ships inside the package.
Is a layer cache always better than a cache action? When an image is already being built, yes, because the layer carries both the browser and its system libraries and the registry deduplicates it across jobs. When there is no image, a cache action is the only option and works well with a correct key. The one case worth avoiding is using both at once: two caches with different invalidation rules eventually disagree, and the resulting bug is very hard to see in a log.
Does a cached browser change what the scan reports? No, provided the cached revision matches the pinned driver — that is the whole reason the guard checks the revision rather than just the directory. A restored bundle is byte-identical to a downloaded one, so contrast ratios, accessible names and every WCAG 2.2 AA finding come out the same. A mismatched revision does change results, which is why a prefix-matching restore key is dangerous rather than merely wasteful.
Related
- Docker-Based Pipeline Execution — the parent guide: pinning the browser, fonts and locale that this cache stores.
- Running Lighthouse CI in a Docker-Based Pipeline — the same container, running
lhci autorunagainst a score threshold. - Web Accessibility Testing: Fundamentals & Tool Selection — choosing the scanner and runner whose binaries you are caching.