Stop Re-Downloading Chromium — Playwright’s Browser Cache Is Shared

Date:

A new virtual environment reports that Playwright is missing, so the reflex is to run the installer and watch 150 MB of Chromium download again. It almost never needs to. The browser you already have is a machine-level asset, and the new project can borrow it.

The short answer: Playwright splits into two parts that live in two different places. The bindings — the playwright package you pip install — are small and belong per project, inside each virtual environment. The browsers — the actual Chromium, Firefox and WebKit builds — are large and belong to the machine, in one shared cache keyed by build number. Adding Playwright to a new project means installing the bindings; it does not mean downloading the browser again. You download a given browser build once per machine, and every project after that reuses it.
One browser download, borrowed by every projectOnly the first step is a download, and it happens once per machine — not once per project.
Once per machine

playwright install downloads the browser: ~150 MB, unpacking to 394 MB on disk.

Shared cache

ms-playwright/ chromium-1208/ — one copy, keyed by build number, not by project.

Per project (venv)

pip install playwright: the bindings wheel, a few MB. No browser download. Repeat per project.

Launch

The bindings resolve executable_path back to the one shared cache and launch the browser there.

Only the first panel touches the network, and only when that build is missing. Everything after it reuses what is already on the machine.

The two halves of a Playwright install

It helps to name the pieces precisely, because the confusion is entirely a naming problem.

The bindings are the language library: pip install playwright in Python, or npm i playwright in Node. This is a small wheel or package — a few megabytes. It contains the API you call, the driver that talks to a browser, and the knowledge of which browser build it expects. It carries no browser itself.

The browsers are separate downloads that Playwright fetches on demand with playwright install. Each is a full browser build — Chromium here weighed in at roughly 150 MB compressed, unpacking to 394 MB on disk for build chromium-1208. These are the heavy artifacts, and they are the ones you do not want to duplicate.

The crucial fact: the browsers are not stored inside your project or your virtual environment. They go into a single per-user cache:

Windows   C:\Users\<you>\AppData\Local\ms-playwright\
macOS     ~/Library/Caches/ms-playwright/
Linux     ~/.cache/ms-playwright/Code language: JavaScript (javascript)

Inside it, one folder per browser build:

ms-playwright/
  chromium-1208/
  chromium_headless_shell-1208/
  ffmpeg-1011/
  winldd-1007/

That -1208 suffix is the whole trick. The cache is keyed by build number, not by project and not by Playwright version. Any set of bindings that expects build 1208 looks in that folder, finds it already populated, and launches it. Ten projects that all want build 1208 share one 394 MB folder between them.

The bindings belong to the project; the browsers belong to the machine — that split is the whole story.

Why a fresh venv looks like it needs a download

Here is the situation that starts the whole misunderstanding, taken from a live machine.

Chromium was already installed globally — ms-playwright/chromium-1208 was sitting in the shared cache, put there by an earlier global install. A separate project had its own virtual environment, and that venv did not have the playwright package. Import it and you get ModuleNotFoundError. The obvious-looking fix is to run the browser installer inside the venv and re-download everything.

That is the wrong fix, and it wastes a 150 MB download. The venv was missing only the bindings. The browser was never the venv’s to hold; it was already on the machine. The correct fix is one line:

pip install playwrightCode language: Bash (bash)

No playwright install chromium afterwards. The venv now has the bindings, the bindings know they want build 1208, and build 1208 is already in the shared cache. Launching works immediately.

You can prove the browser is shared rather than copied. From either interpreter — the global one or the venv — ask Playwright where its Chromium lives:

from playwright.sync_api import sync_playwright
with sync_playwright() as p:
    print(p.chromium.executable_path)
Code language: Python (python)

Both print the same path, ending in ms-playwright/chromium-1208/…/chrome. Two interpreters, one browser binary on disk. The venv did not acquire its own copy; it resolved the one already there.

A fresh venv is missing the bindings, not the browser — reinstalling the browser re-downloads what you already have.

When a download does happen

The cache being keyed by build number tells you exactly when Playwright reaches for the network, and it is not often:

  • The build you need is not in the cache. A clean machine, or a browser you have never installed (Firefox, WebKit) on a machine that only had Chromium.
  • You upgrade the bindings and they expect a newer build. Bump the playwright package far enough and it will expect, say, chromium-1250; that folder does not exist yet, so playwright install fetches it. The old chromium-1208 stays put — which is why the cache slowly accumulates builds and is worth pruning with playwright uninstall --all when you are between versions.
  • You explicitly run playwright install. The command’s whole job is to make sure the expected builds are present, so it downloads any that are missing. Run against a cache that already has them, it prints that they are up to date and downloads nothing.

Notice what is not on that list: creating a new project, making a new virtual environment, or installing the bindings into one. None of those touch the browser cache.

Pointing at the shared browser cache on purpose: PLAYWRIGHT_BROWSERS_PATH

The default per-user location is fine for a workstation, but you sometimes want to move or share it — a CI runner, a locked-down build agent, a team that keeps browsers on a shared volume. One environment variable controls it:

export PLAYWRIGHT_BROWSERS_PATH=/opt/ms-playwrightCode language: Bash (bash)

Set it and both halves obey it: playwright install downloads into that directory, and the bindings look there at launch. The rule is simply that they must agree — install and run with the same value set, or the runtime looks in the default location while the browser sits in your custom one, and you get a “browser not found” error that looks like a failed download but is really a lookup pointed at the wrong shelf.

Two special values are worth knowing:

  • PLAYWRIGHT_BROWSERS_PATH=0 puts the browser back inside the bindings package directory — the opposite of sharing. Occasionally wanted for a fully self-contained, relocatable install; almost never what you want on a normal machine, since it defeats the shared cache.
  • PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 installs the bindings and suppresses the automatic browser fetch some install flows trigger. Useful precisely when you know the browser is already cached and you only want the wheel.

CI: cache the browsers, install the bindings every time

The same split gives CI a clean rule. Your bindings come from your normal dependency install (pip install, npm ci) on every job — they are small and you want them pinned with everything else. The browsers are what you cache between runs, because re-downloading 150 MB on every push is the waste the shared cache exists to avoid — on a metered runner, billed CI minutes you needn’t spend.

So: cache the ms-playwright directory, keyed on your lockfile (which pins the Playwright version, which determines the build number), and gate the download on a cache miss.

# sketch — adapt to your CI's cache action
- cache:
    path: ~/.cache/ms-playwright
    key: playwright-${{ hashFiles('**/requirements.txt') }}
- run: pip install -r requirements.txt        # bindings, every run
- run: playwright install --with-deps chromium # no-op on a cache hit
Code language: YAML (yaml)

On a hit, playwright install finds the build already present and returns in a second or two. On a miss — a new Playwright version, so a new build number, so a new cache key — it downloads once and the next run hits again. Keying the cache on the lockfile rather than a fixed string is what makes the invalidation line up with the only event that actually needs a fresh browser: a version bump.

The --with-deps flag is a separate concern worth a word: on Linux it also installs the system libraries Chromium needs (fonts, graphics libs). Those are OS packages, not part of the browser cache, so they are not covered by caching the ms-playwright directory — hence installing them as part of the step rather than relying on the cache.

Cache the browsers, install the bindings every run — one rule that keeps CI both correct and cheap.

Two clean ways to use one machine’s browser from many projects

If your goal is several projects driving the same installed browser, there are two tidy patterns and one anti-pattern.

Pattern one — bindings per venv, shared cache underneath. Each project’s virtual environment installs its own playwright bindings; all of them resolve to the same ms-playwright cache. This is the default behavior described above and it is usually the right answer: projects stay isolated (each pins its own Playwright version), while the heavy browsers are shared automatically. The only cost is a few-megabyte wheel per venv, which is noise.

Pattern two — one shared interpreter, called as a subprocess. Keep a single environment that has Playwright installed and a small script that does the browser work, and have your other projects invoke it as a subprocess rather than importing it. This suits a project that is not even Python, or one that must not take on Playwright as a direct dependency but still needs a page rendered or a PDF generated. You trade in-process control for a hard dependency boundary.

The anti-pattern is copying the ms-playwright folder into each project, or setting PLAYWRIGHT_BROWSERS_PATH=0 everywhere so every venv bundles its own browser. That is the 150-MB-per-project world the design specifically avoids. If you find yourself duplicating the browser, step back — the shared cache was already doing the job.

The short version

Playwright is one small per-project library plus one large per-machine asset, and the two are stored apart on purpose. A new project needs the library, not the asset. So the reflexive playwright install after every pip install is, on any machine that has already downloaded the browser once, a 150 MB no-op waiting to happen. Install the bindings, let them find the browser that is already there, and confirm it with a one-line executable_path check if you want to see the sharing for yourself. It is a small measured fact with an outsized payoff — the same genre as why a database’s connection limit is not its concurrency limit.

Frequently Asked Questions

Do I need to run playwright install in every new virtual environment?

No — only pip install playwright. The browser lives in a machine-level cache, not in the venv, so if the build your bindings expect is already cached, the bindings find it and launch it with no download. Run playwright install only when the expected build is genuinely missing.

Where does Playwright actually store its browsers?

In a per-user cache: %LOCALAPPDATA%\ms-playwright on Windows, ~/Library/Caches/ms-playwright on macOS, and ~/.cache/ms-playwright on Linux. Inside it there is one folder per browser build, named like chromium-1208. Override the location with the PLAYWRIGHT_BROWSERS_PATH environment variable.

How much space does the browser take?

The Chromium download is roughly 150 MB compressed; unpacked it is close to 400 MB on disk per build. The point of the shared cache is that this cost is paid once per build, not once per project — ten projects on the same build share the one folder.

When does Playwright re-download a browser?

Only when the build it needs is not already cached: a clean machine, a browser family you have not installed before, or a Playwright upgrade whose bindings expect a newer build number. Creating projects or installing the bindings never triggers a download on its own.

What does PLAYWRIGHT_BROWSERS_PATH do?

It sets the directory both halves use — playwright install writes browsers there and the bindings look there at launch. They must be set to the same value for install and run, or the runtime searches the default location while the browser sits in your custom one. The special value 0 bundles the browser inside the bindings package instead of sharing it.

How do I confirm a project is using the shared browser and not its own copy?

Print p.chromium.executable_path from inside sync_playwright(). If it points into ms-playwright, the project is using the shared cache. Do it from two different interpreters and compare — matching paths mean one browser binary is serving both.

Share post:

Subscribe

Popular

More like this
Related

How to Merge Spreadsheets by URL (and Why Our Tool Reported 5 Misses That Weren’t)

Matching two spreadsheets on a URL column is the same job as matching a stock list to a price list by product code. It goes wrong in the same two places: how you clean the code, and how you count what didn't match.

Many-to-One: Why a Mapping Backlog Outgrows Its Target Taxonomy

You're mapping a messy source vocabulary onto a clean target taxonomy. The target has about 5,600 slots, so the job must be roughly 5,600 decisions — right? The backlog is already past 8,900, and still climbing.

The Fix That Didn’t Take: When a Content-Hash Gate Skips Your Code Change

You shipped a fix to how one field is computed, re-ran the pipeline across everything, and it reported a clean run. Then the consistency check still flags those records as wrong. The fix is in the code, the rebuild "ran" — and the records never changed. Your own optimisation skipped them.

Your Dedup Scan Is Over-Counting — Because It Forgot What You Already Fixed

The report said 80 duplicate groups still to resolve. You'd been resolving them for days. You run it again after a big batch and it *still* says 80-ish. The backlog isn't stubborn — the scan is counting work you already finished.