BLOG
Puppeteer Browsers Install Chrome: Local vs Remote
Learn how puppeteer browsers install chrome works, where the binaries land, and when to skip local installs for a hosted Chromium runtime.
# Puppeteer Browsers Install Chrome: Local vs Remote
puppeteer browsers install chrome is the command Puppeteer gives you to download a specific Chrome build into its local cache instead of relying on whatever browser happens to be on the machine. It exists because modern Puppeteer decoupled browser binaries from the npm package: npm install puppeteer no longer guarantees a working Chrome, and puppeteer browsers install chrome is the explicit fix. This guide covers what that command actually does, where the files go, how to pin versions, and when the right answer is to stop installing Chrome locally and connect to a hosted Chromium session instead.
What puppeteer browsers install chrome actually does
Puppeteer ships a CLI (@puppeteer/browsers under the hood) that resolves a browser name and build ID, downloads the correct archive for your OS and architecture, verifies it, and extracts it into a cache directory. When you run:
npx puppeteer browsers install chromePuppeteer resolves the "chrome" channel to a pinned build, downloads it, and prints the install path. That path is what puppeteer.launch() uses when no executablePath is supplied.
Key behaviors worth knowing:
- It installs into a cache, not `node_modules`. The default location is
~/.cache/puppeteeron Linux/macOS and%USERPROFILE%\.cache\puppeteeron Windows. You can override it withPUPPETEER_CACHE_DIR. - It is version-pinned, not "latest Chrome." Puppeteer maps its own release to a compatible Chrome for Testing build. That is deliberate: it keeps the DevTools Protocol surface aligned with the client library.
- It supports channels.
chrome,chrome-headless-shell,chromium, andchrome@stable/chrome@beta/chrome@canaryare all valid targets.chrome-headless-shellis the lighter headless-only binary. - It is idempotent. Re-running the command against an already-installed build is a no-op.
If you want to see what is installed and where:
npx puppeteer browsers list
npx puppeteer browsers install chrome@stable
npx puppeteer browsers install chrome-headless-shellWhere the binaries land and why it matters
The cache layout looks roughly like this:
~/.cache/puppeteer/
chrome/
linux-127.0.6533.88/
chrome-linux64/chrome
chrome-headless-shell/
linux-127.0.6533.88/
chrome-headless-shell-linux64/chrome-headless-shellThree practical consequences:
- Container images get large. A full Chrome build adds a substantial amount of disk. If you build a Docker image per deploy, you are re-downloading or re-baking that binary every time.
- Cache directories are per-user, not per-project. Two projects pinning different Chrome builds share one cache root. That is usually fine, but it means disk usage grows silently.
- CI runners start cold. A fresh GitHub Actions runner has no cache. Either you cache
~/.cache/puppeteerbetween runs or you pay the download on every job.
You can point Puppeteer at a specific binary if you already have one:
npx puppeteer browsers install chrome --path /opt/browsersAnd in code:
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
executablePath: process.env.CHROME_PATH,
headless: true,
});Pinning versions and the puppeteer browsers npm workflow
The puppeteer browsers CLI is part of the @puppeteer/browsers package, which is a dependency of puppeteer and puppeteer-core. You can install it standalone if you want the downloader without the full client:
npm install @puppeteer/browsers
npx @puppeteer/browsers install chrome@127.0.6533.88This matters for reproducibility. If your test suite passes against Chrome 127 locally and your CI pulls Chrome 130, you will eventually chase a protocol or rendering difference that has nothing to do with your code. Pin the build ID in a script or a .puppeteerrc.cjs:
// .puppeteerrc.cjs
module.exports = {
cacheDirectory: '/opt/puppeteer-cache',
};A common pattern is a postinstall script that installs the exact build your package.json expects, so a fresh clone is runnable without a manual step.
When local installs stop scaling
Local Chrome installs are fine for a laptop and a single test suite. They get awkward when:
- You run many concurrent sessions. Each Chrome process is memory-hungry. Ten parallel sessions on one box is a capacity planning problem, not a config problem.
- You need persistent profiles. Cookies, localStorage, and logged-in state have to survive across runs, which means managing profile directories and locking.
- You need a different IP. Local Chrome uses your machine's network. Residential or regional proxies require either a proxy config or a runtime that handles it.
- You want to debug a live session. A headless process on a CI runner is not something you can watch.
- You want the same environment across languages. If part of your stack is Python and part is Node, you now maintain two browser install paths.
This is the point where teams move the browser out of the application process and into a runtime they connect to. Remote Browser provides hosted Chromium sessions with CDP access, so Puppeteer, Playwright, and Selenium clients connect the same way they would to a local Chrome — just over a WebSocket endpoint instead of a spawned process. The remote browser for AI agents post covers the runtime model in more depth.
Local install vs hosted Chromium: a comparison
| Dimension | Local puppeteer browsers install chrome | Hosted Chromium session |
|---|---|---|
| Setup | Download binary per machine/CI runner | Connect to a WebSocket endpoint |
| Version control | Pin build ID, manage cache dir | Runtime-managed; request a version |
| Concurrency | Bounded by host RAM/CPU | Scales independently of your app process |
| Persistent profiles | Manual profile dirs and locking | Managed profiles per session |
| Proxy / network | Configure per launch | Configurable at the session level |
| Live debugging | Attach a local viewer or DevTools | Built-in live viewer |
| Cross-language | One install path per runtime | Same endpoint for Node, Python, etc. |
| Cost model | Compute you already pay for | Metered per browser-hour — see /pricing |
| Best for | Local dev, small test suites | Production agents, parallel workloads |
Neither column is universally better. The trade-off is control versus operational overhead. If you need a specific patched Chrome build for a compliance reason, local installs give you that. If you need fifty concurrent sessions with isolated profiles and a live view, a hosted runtime is the shorter path.
Connecting Puppeteer to a remote Chromium session
The connection pattern is the same one Puppeteer uses for any remote Chrome: puppeteer.connect() with a browserWSEndpoint. You get that endpoint from your runtime.
import puppeteer, { Browser, Page } from 'puppeteer-core';
interface SessionInfo {
browserWSEndpoint: string;
sessionId: string;
}
async function runTask(session: SessionInfo): Promise<void> {
const browser: Browser = await puppeteer.connect({
browserWSEndpoint: session.browserWSEndpoint,
defaultViewport: { width: 1280, height: 800 },
});
try {
const page: Page = await browser.newPage();
// Route through the session's network settings if configured.
await page.goto('https://example.com', {
waitUntil: 'networkidle2',
timeout: 30_000,
});
const title = await page.title();
console.log(`session=${session.sessionId} title=${title}`);
// CDP is available directly when you need lower-level control.
const client = await page.createCDPSession();
await client.send('Network.enable');
} finally {
// Disconnect, don't close — the runtime owns the browser lifecycle.
await browser.disconnect();
}
}Two details that trip people up:
- Use `puppeteer-core`, not `puppeteer`. The full package tries to manage a local binary.
puppeteer-coreis the client only, which is what you want when connecting to a remote endpoint. - Call `disconnect()`, not `close()`.
close()terminates the browser. On a hosted runtime you usually want the session to persist or be reclaimed by the platform, not killed mid-flight.
The same endpoint works from Playwright via connectOverCDP, which is useful if you are migrating between libraries. The remote web browser post walks through the Playwright side.
Production criteria before you commit
Before you decide between local installs and a hosted runtime, answer these:
- How many concurrent sessions do you need at peak? If it is more than a handful, local Chrome on one host becomes a scheduling problem.
- Do sessions need to survive a deploy? Persistent profiles and session isolation are runtime features, not client features.
- What is your failure mode? A crashed local Chrome takes down the process that spawned it. A remote session can be reconnected or replaced.
- How do you debug a failure? Screenshots and logs are the minimum. A live viewer that lets you watch the session in real time is materially better.
- What is your network requirement? If you need regional egress or proxy configuration, that belongs at the session layer.
- What does it cost per session-hour? Compare the compute you are already paying for against a metered model. Current rates are on /pricing.
If most of those answers point to "we need isolation, persistence, and observability," the install command is not the bottleneck — the architecture is.
Migrating from local installs without breaking things
A staged migration works better than a rewrite:
- Keep `puppeteer browsers install chrome` for local dev. Developers get fast iteration and offline capability.
- Introduce an endpoint abstraction. Read the WebSocket endpoint from an environment variable. Locally it is unset and you launch a local browser; in staging and production it points at a hosted session.
- Move profile and proxy concerns to the runtime. Stop writing profile directories in your application code.
- Add the live viewer to your debugging workflow. Being able to watch a failing session is the fastest way to diagnose selector drift and timing issues.
- Retire the CI cache step. Once sessions are remote, your CI job no longer needs to download or cache Chrome.
const endpoint = process.env.BROWSER_WS_ENDPOINT;
const browser = endpoint
? await puppeteer.connect({ browserWSEndpoint: endpoint })
: await puppeteer.launch({ headless: true });That single branch is the whole migration. Everything else is configuration.
Where to go next
If you are still evaluating, start with the documentation to see the connection model and session controls. If you want to understand how hosted sessions differ from a local Chrome install in practice, read remote browser online. And if you are driving sessions from an agent rather than a test suite, remote control browser covers the control-plane side.
For the underlying protocol details, the Chrome DevTools Protocol documentation is the authoritative reference for the domains Puppeteer and Playwright expose.
puppeteer browsers install chrome is the right tool for local development and small test suites. It is the wrong tool for production workloads that need concurrency, persistence, and observability. Knowing which one you are building is the actual decision.