Introduction: the command that assumes a network
npx playwright install is one line in every getting-started guide, and it hides a download of several hundred megabytes from a Microsoft CDN. On a laptop that is invisible. On a machine inside a segregated network it is the point where the whole setup stops.
What follows is a complete procedure for one machine: what to fetch on the connected side, how to move it, where to put it, and how to prove the browser actually starts. No product pitch, nothing you have to buy. If you only ever do this once, this is the page you needed.
1What playwright install actually does
Three things happen when you run it, and separating them is what makes the offline version possible:
- It reads the browser revisions pinned by the installed
@playwright/testversion. Every Playwright release is bound to specific Chromium, Firefox and WebKit builds. - It downloads the matching archives from the CDN, by default to a per-user cache.
- It unpacks them and, with
--with-deps, installs system libraries through the distribution package manager.
Two consequences follow immediately. The cache is version-specific, so a cache built for 1.47 will not satisfy 1.49. And it is platform-specific, so a cache built on macOS arm64 is useless on Linux x64. Both mistakes are common and both produce confusing errors on the far side.
2Where the browsers live
By default the cache goes to a per-user directory:
| Platform | Default location |
|---|---|
| Linux | ~/.cache/ms-playwright |
| macOS | ~/Library/Caches/ms-playwright |
| Windows | %USERPROFILE%\AppData\Local\ms-playwright |
Per-user is the wrong shape for a server. Set an explicit path on both machines and the rest of the procedure becomes symmetrical:
export PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwrightThe value has to match on the connected machine and the isolated one. Playwright records nothing about the absolute path inside the archives, but your own scripts, containers and service definitions will refer to it, and a mismatch is the second most common failure after the version mismatch.
3On the connected machine
Match the environment you are shipping to. Same operating system family, same CPU architecture, same Playwright version. If the target is Linux x64 and you work on an Apple laptop, do this step in a container or on a build agent, not natively.
mkdir -p /tmp/pw-export && cd /tmp/pw-export
# Pin the exact version you will run on the far side.
npm init -y >/dev/null
npm install --save-exact @playwright/test@1.47.0
export PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright
npx playwright install chromium firefox webkitTake only what you need. Most suites run on Chromium alone, and each engine is a substantial fraction of the transfer. If you are unsure, check playwright.config.ts for the projects that are actually enabled.
Now package the cache together with the evidence of what it is:
cd /opt
tar czf /tmp/pw-export/ms-playwright-1.47.0-linux-x64.tar.gz ms-playwright
cd /tmp/pw-export
npx playwright --version > MANIFEST.txt
uname -sm >> MANIFEST.txt
ls "$PLAYWRIGHT_BROWSERS_PATH" >> MANIFEST.txt
sha256sum ms-playwright-*.tar.gz > SHA256SUMSThe manifest matters more than it looks. Six months later, when a second archive appears on the same file share, the only thing distinguishing them is what you wrote down. List the revisions as well as the version: the person on the far side needs to know what the archive satisfies, not just what produced it.
System libraries
--with-deps installs distribution packages, and those come from your operating system repositories rather than from Playwright. Offline, they need the same treatment:
# See what would be installed, without installing it
npx playwright install-deps --dry-runTake the resulting package list to your distribution's offline tooling: a local mirror, or downloaded .deb/.rpm files with their dependencies. On a minimal server image this is usually where the real work is, not in the browser archives.
The container alternative
There is a second route, and for some environments it is the better one. Microsoft publishes an official image with the browsers and their system libraries already inside:
docker pull mcr.microsoft.com/playwright:v1.47.0-jammy
docker save mcr.microsoft.com/playwright:v1.47.0-jammy \
| gzip > playwright-1.47.0-jammy.tar.gzThat single artefact replaces both the browser cache and the system library problem, which is most of the work above. It is the right choice when there is an image registry on the far side and the transfer budget can absorb it.
It is the wrong choice when the transfer is the constraint. The image is several times larger than the browser cache alone, the browser versions are tied to the image tag, and the multi-version arrangement in section 7 does not apply - carrying four versions means carrying four images.
4On the isolated machine
Two things have to be there before any of this runs, and neither comes from Playwright: Node.js itself, and the project's npm dependencies. npm ci on a machine with no route out fails immediately, because there is no registry to reach. Either the node_modules directory travels with the package, or the dependencies arrive as tarballs, or your organisation has an internal registry. That is a separate problem with a separate procedure, and it is covered in its own article in this series.
Assuming the dependencies are in place, install them without triggering a browser download:
export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
npm ciThat variable is often misunderstood. It does not make Playwright run without browsers. It only suppresses the download step during npm install, so that the install does not fail on a machine with no route out. The browsers still have to be present.
Unpack the cache to the same path you exported from:
sha256sum -c SHA256SUMS
sudo tar xzf ms-playwright-1.47.0-linux-x64.tar.gz -C /opt --no-same-owner
export PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright--no-same-owner matters more than it looks. Without it, tar restores the numeric user and group from the machine that built the archive. If that UID does not exist here, the files end up owned by a number nobody can read, and the result surfaces later as a permission error that looks like a completely different problem.
Make the path variable permanent for whatever runs the tests: the systemd unit, the container environment, the CI runner definition. A path that only exists in your interactive shell will work while you are testing and fail at three in the morning.
5Verifying, before you trust it
Three checks, in increasing order of confidence.
The resolution check. Ask Playwright what it would download. Every line should refer to a browser that is already present:
npx playwright install --dry-runThe launch check. Starting a browser exercises the binary, its shared libraries and its sandbox, which is where missing system packages actually surface:
node -e "require('@playwright/test').chromium.launch().then(b => b.close()).then(() => console.log('chromium ok'))"The real check. Run one actual test against something reachable inside the network. A browser that launches can still fail to render, and a suite that passes locally can still time out on a page that never finishes loading because it waits for a font from a CDN.
6The failures you will actually hit
Executable doesn't exist at .../chrome-linux/chrome The path is wrong, or the version is. Compare the revision directory names under your cache with what npx playwright install --dry-run expects. Nine times out of ten the Playwright version on the two machines differs by a minor release.
error while loading shared libraries: libnss3.so The archives contain browsers, not system libraries. Go back to the install-deps list and get those packages onto the machine.
Chromium starts, then dies without a message Almost always /dev/shm. The default 64 MB in a container is not enough:
services:
tests:
shm_size: "2gb" # or ipc: hostPermission denied when several users run tests A cache under one user's home directory, unpacked with restrictive permissions, or restored with an owner that does not exist here. This is what the explicit shared path and --no-same-owner avoid.
It worked yesterday and today it downloads again Something bumped @playwright/test, usually a lockfile that was not committed or a ^ range in package.json. Pin the exact version and commit the lock.
7Several versions in the same cache
The cache is addressed by browser revision, not by Playwright version. A real directory looks like this:
/opt/ms-playwright/
chromium-1155/ chromium_headless_shell-1200/
chromium-1200/ chromium_headless_shell-1208/
chromium-1208/ firefox-1471/ webkit-2123/
chromium-1217/ firefox-1497/ webkit-2227/
chromium-1223/ firefox-1522/ webkit-2248/Five Chromium revisions, side by side. They coexist happily, but not by default, and the mechanism is worth understanding before you rely on it.
Playwright garbage-collects browsers it thinks nobody needs
Next to the revisions sits a .links directory. Every install writes a file there pointing at the playwright-core package that requested it:
/opt/ms-playwright/.links/
18c303ab... -> /projects/suite-a/node_modules/playwright-core
3ba23eb0... -> /projects/suite-b/node_modules/playwright-core
7f0e6cf6... -> /usr/lib/python3.12/site-packages/playwright/driver/packageOn every playwright install, Playwright reads that registry, works out which revisions are still required by an installation that exists on disk, and deletes the rest. So the five revisions above survive because there are five live projects asking for them, not because installs are additive.
Which means the obvious way to build a multi-version cache does not work:
# WRONG: each install replaces node_modules, so the previous version is no longer
# registered, and its browsers are collected on the next run.
for v in 1.47.0 1.49.0; do
npm install --save-exact @playwright/test@"$v"
npx playwright install chromium
doneRun that and you end up with one revision, not two. Measured, not assumed:
after 1.47.0 chromium-1134 ffmpeg-1010
after 1.49.0 chromium-1148 chromium_headless_shell-1148 ffmpeg-1010chromium-1134 is gone.
Turning the collection off
There is a switch, and it is an environment variable rather than a command-line flag:
PLAYWRIGHT_SKIP_BROWSER_GC=1 npx playwright install chromiumWith it set, the same loop keeps everything:
after 1.47.0 chromium-1134
after 1.49.0 chromium-1134 chromium-1148 chromium_headless_shell-1148Two properties of that variable are worth knowing. It only suppresses the cleanup; the install itself behaves normally. And playwright uninstall deliberately ignores it, so removal always works even if the variable is set globally in your shell profile.
The alternative, if you would rather not depend on an environment variable, is to keep each version's project directory around, so every install stays registered in .links. That is what produces a cache like the one at the top of this section on a developer machine: nobody planned it, the projects simply still exist.
Why this matters behind an air gap
Projects migrate independently. Two suites on the same runner can sit on different Playwright versions, each resolving to its own revisions out of the same shared directory. Nobody has to be upgraded on somebody else's schedule.
Rollback stops being a transfer. If a new version misbehaves against the application under test, going back is a change to package.json and a reinstall from the local cache. Without the older revisions present, the same rollback means another trip through the customer's transfer process, which is measured in weeks.
One window covers several upgrades. Since the expensive part is the transfer and the approval, not the disk space, carrying three or four versions through a single window is almost free compared with doing it three or four times.
Building a multi-version cache
Same procedure as section 3, repeated against the same path, with the collection suppressed so each pass keeps what the previous one fetched:
export PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwright
export PLAYWRIGHT_SKIP_BROWSER_GC=1
for v in 1.44.0 1.45.3 1.46.1 1.47.0; do
npm install --save-exact @playwright/test@"$v"
npx playwright install chromium # add firefox webkit if you need them
done
ls "$PLAYWRIGHT_BROWSERS_PATH" # verify every revision is still thereCheck the directory listing rather than trusting the loop. If a revision is missing, the variable was not exported into the environment the install actually ran in, which is easy to get wrong inside a CI step or a container.
Then archive the whole directory once. The manifest should list every Playwright version the cache satisfies, not just the newest, because that list is what someone on the far side needs in order to know what they can install without asking for anything.
What it costs
Roughly 1 GB per version for all three engines, or a few hundred megabytes for Chromium alone. Ten versions of all three engines is something like 10 GB in the transfer, which is a deliberate trade rather than an obvious win: cheap if your constraint is the approval process, expensive if it is the physical media.
Chromium ships a separate headless shell (chromium_headless_shell-<revision>), so the number of directories is larger than the number of browsers you think you installed. And ffmpeg-<revision> appears once, shared, used for video recording.
Pruning, deliberately
If you suppressed the collection, nothing is removed automatically any more, so a long-lived cache grows. Prune it on the same cadence you upgrade, and only against a written record of which project uses which version:
npx playwright install --list # browsers across all installations, grouped by version
npx playwright uninstall # revisions for the current installation only
npx playwright uninstall --all # everything Playwright has installed at this pathWithout --all, uninstall reports how many browsers remain because other installations still use them, which is the number you want before deciding anything is safe to delete.
--all is blunter than it looks in a shared environment: it removes revisions other projects may still be using. On a runner hosting several suites, prefer deleting specific revision directories against your register.
8Keeping it repeatable
For one machine, the procedure above is enough. The moment there are two, write down four things and keep them next to the archive: the Playwright version, the platform triple, the browsers included and the checksum. A tarball with no manifest is indistinguishable from any other tarball, and the failure it causes appears weeks later as an unexplained version mismatch.
If you find yourself repeating this monthly, you have outgrown the manual procedure and want an internal mirror instead: the same archives served from your own artifact repository, with PLAYWRIGHT_DOWNLOAD_HOST pointed at it. That is a different setup and a different article.
One thing this article deliberately leaves out is what happens when the official route exists but takes longer than the work allows. That is not a technical problem and it does not belong in a procedure, but it is the reason the procedure so often gets skipped.
9Conclusion
Nothing here is difficult. It is the kind of task that takes twenty minutes once you know the three moving parts, and half a day the first time because the failures point at the wrong things: a missing system library looks like a broken download, and a version mismatch looks like a corrupted archive.
The three parts, one more time:
- The cache is version- and platform-specific. Build it on a machine that matches the target, with the version pinned.
PLAYWRIGHT_SKIP_BROWSER_DOWNLOADonly suppresses the download. The browsers still have to arrive some other way.- System libraries are a separate problem.
install-depstells you what is needed; getting those packages in is your distribution's problem, not Playwright's.
And one thing worth doing on the way in: carry more than one version. Revisions can coexist in the same cache, so the marginal cost is disk rather than another trip through the transfer process, which turns a rollback from a three-week request into a line in package.json. Just remember that coexistence is not the default: without PLAYWRIGHT_SKIP_BROWSER_GC=1, each install quietly collects the revisions no live installation still claims.